diff --git a/app/components/chips/base_chip.test.tsx b/app/components/chips/base_chip.test.tsx
index 942d336aa..a53e196ea 100644
--- a/app/components/chips/base_chip.test.tsx
+++ b/app/components/chips/base_chip.test.tsx
@@ -28,26 +28,39 @@ describe('BaseChip', () => {
expect(getByText('Test Label')).toBeTruthy();
});
- it('should render with the X button when showRemoveOption is true', () => {
+ it('should render with the X button when actionIcon is remove', () => {
const {getByTestId} = renderWithIntlAndTheme(
,
);
expect(getByTestId('base_chip.remove.button')).toBeTruthy();
});
- it('should not render the X button when showRemoveOption is false', () => {
+ it('should render with the chevron down button when actionIcon is downArrow', () => {
+ const {getByTestId} = renderWithIntlAndTheme(
+ ,
+ );
+
+ expect(getByTestId('base_chip.downArrow.button')).toBeTruthy();
+ });
+
+ it('should not render the X button when actionIcon is undefined', () => {
const {queryByTestId} = renderWithIntlAndTheme(
,
);
@@ -60,7 +73,7 @@ describe('BaseChip', () => {
onPress={onPressMock}
label='Test Label'
testID='base_chip'
- showRemoveOption={true}
+ actionIcon='remove'
/>,
);
@@ -74,7 +87,7 @@ describe('BaseChip', () => {
onPress={onPressMock}
label='Test Label'
testID='base_chip'
- showRemoveOption={false}
+ actionIcon={undefined}
/>,
);
diff --git a/app/components/chips/base_chip.tsx b/app/components/chips/base_chip.tsx
index 8d437eee8..b041fd0d5 100644
--- a/app/components/chips/base_chip.tsx
+++ b/app/components/chips/base_chip.tsx
@@ -17,7 +17,7 @@ import {CHIP_HEIGHT} from './constants';
type SelectedChipProps = {
onPress?: () => void;
testID?: string;
- showRemoveOption?: boolean;
+ actionIcon?: 'remove' | 'downArrow';
showAnimation?: boolean;
label: string;
prefix?: JSX.Element;
@@ -70,7 +70,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
export default function BaseChip({
testID,
onPress,
- showRemoveOption,
+ actionIcon,
showAnimation,
label,
prefix,
@@ -85,7 +85,7 @@ export default function BaseChip({
// We set the max width to 70% of the screen width to make sure
// text like names get ellipsized correctly.
const textMaxWidth = maxWidth || dimensions.width * 0.70;
- const marginRight = showRemoveOption ? undefined : 7;
+ const marginRight = actionIcon ? undefined : 7;
const marginLeft = prefix ? 5 : 7;
return [
style.text,
@@ -94,7 +94,7 @@ export default function BaseChip({
type === 'danger' && style.dangerText,
boldText && style.boldText,
];
- }, [maxWidth, dimensions.width, showRemoveOption, prefix, style.text, style.linkText, style.dangerText, style.boldText, type, boldText]);
+ }, [maxWidth, dimensions.width, actionIcon, prefix, style.text, style.linkText, style.dangerText, style.boldText, type, boldText]);
const containerStyle = useMemo(() => {
return [
style.container,
@@ -116,19 +116,20 @@ export default function BaseChip({
);
let content = chipContent;
- if (showRemoveOption) {
+ if (actionIcon) {
+ const iconName = actionIcon === 'remove' ? 'close-circle' : 'chevron-down';
content = (
<>
{chipContent}
>
diff --git a/app/components/chips/selected_chip.test.tsx b/app/components/chips/selected_chip.test.tsx
index 6a622e4f3..f95fadaf7 100644
--- a/app/components/chips/selected_chip.test.tsx
+++ b/app/components/chips/selected_chip.test.tsx
@@ -32,7 +32,7 @@ describe('SelectedChip', () => {
const baseChip = getByTestId('selected-chip');
expect(baseChip.props.label).toBe('Test Chip');
- expect(baseChip.props.showRemoveOption).toBe(true);
+ expect(baseChip.props.actionIcon).toBe('remove');
expect(baseChip.props.showAnimation).toBe(true);
expect(baseChip.props.prefix).toBeUndefined();
baseChip.props.onPress();
diff --git a/app/components/chips/selected_chip.tsx b/app/components/chips/selected_chip.tsx
index efb0b17e8..2f4ddbbb6 100644
--- a/app/components/chips/selected_chip.tsx
+++ b/app/components/chips/selected_chip.tsx
@@ -26,7 +26,7 @@ export default function SelectedChip({
diff --git a/app/components/chips/selected_user_chip.test.tsx b/app/components/chips/selected_user_chip.test.tsx
index 6ef3ab2b2..303156984 100644
--- a/app/components/chips/selected_user_chip.test.tsx
+++ b/app/components/chips/selected_user_chip.test.tsx
@@ -32,7 +32,7 @@ describe('SelectedUserChip', () => {
const userChip = getByTestId('selected-user-chip');
expect(userChip.props.user).toBe(mockUser);
expect(userChip.props.teammateNameDisplay).toBe('username');
- expect(userChip.props.showRemoveOption).toBe(true);
+ expect(userChip.props.actionIcon).toBe('remove');
expect(userChip.props.showAnimation).toBe(true);
userChip.props.onPress();
expect(mockOnPress).toHaveBeenCalledTimes(1);
diff --git a/app/components/chips/selected_user_chip.tsx b/app/components/chips/selected_user_chip.tsx
index efb098e80..a05087d7f 100644
--- a/app/components/chips/selected_user_chip.tsx
+++ b/app/components/chips/selected_user_chip.tsx
@@ -24,7 +24,7 @@ export default function SelectedUserChip({
({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(SelectedUserChipByIdComponent).mockImplementation((props) => React.createElement('SelectedUserChipById', {...props}));
+
+describe('SelectedUserChipById Enhanced Component', () => {
+ const userId = 'user-id';
+
+ let database: Database;
+ let operator: ServerDataOperator;
+
+ function getBaseProps(): ComponentProps {
+ return {
+ onPress: jest.fn(),
+ teammateNameDisplay: General.TEAMMATE_NAME_DISPLAY.SHOW_USERNAME,
+ userId,
+ testID: 'test-chip',
+ };
+ }
+
+ async function addUserToDatabase(user: UserProfile) {
+ await operator.handleUsers({
+ users: [user],
+ prepareRecordsOnly: false,
+ });
+ }
+
+ beforeEach(async () => {
+ await DatabaseManager.init([serverUrl]);
+ const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
+ database = serverDatabaseAndOperator.database;
+ operator = serverDatabaseAndOperator.operator;
+ });
+
+ afterEach(() => {
+ DatabaseManager.destroyServerDatabase(serverUrl);
+ });
+
+ describe('enhanced component behavior', () => {
+ it('should render correctly with no user data initially', () => {
+ const baseProps = getBaseProps();
+ baseProps.userId = 'non-existent-user-id';
+ const {getByTestId} = renderWithEverything(
+ ,
+ {database},
+ );
+
+ const chip = getByTestId('test-chip');
+ expect(chip.props.user).toBeUndefined();
+ });
+
+ it('should render correctly with user data after database population', async () => {
+ await addUserToDatabase(TestHelper.fakeUser({id: userId}));
+ const baseProps = getBaseProps();
+ baseProps.userId = userId;
+
+ const {getByTestId} = renderWithEverything(
+ ,
+ {database},
+ );
+
+ const chip = getByTestId('test-chip');
+ expect(chip).toHaveProp('user', expect.objectContaining({id: userId}));
+ });
+ });
+});
diff --git a/app/components/chips/selected_user_chip_by_id/index.ts b/app/components/chips/selected_user_chip_by_id/index.ts
new file mode 100644
index 000000000..d09790529
--- /dev/null
+++ b/app/components/chips/selected_user_chip_by_id/index.ts
@@ -0,0 +1,20 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
+
+import {observeUser} from '@queries/servers/user';
+
+import SelectedUserChipById from './selected_user_chip_by_id';
+
+import type {WithDatabaseArgs} from '@typings/database/database';
+
+type OwnProps = WithDatabaseArgs & {
+ userId: string;
+}
+
+const enhanced = withObservables(['userId'], ({database, userId}: OwnProps) => ({
+ user: observeUser(database, userId),
+}));
+
+export default withDatabase(enhanced(SelectedUserChipById));
diff --git a/app/components/chips/selected_user_chip_by_id/selected_user_chip_by_id.test.tsx b/app/components/chips/selected_user_chip_by_id/selected_user_chip_by_id.test.tsx
new file mode 100644
index 000000000..ecfe558bf
--- /dev/null
+++ b/app/components/chips/selected_user_chip_by_id/selected_user_chip_by_id.test.tsx
@@ -0,0 +1,53 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {type ComponentProps} from 'react';
+
+import SelectedUserChip from '@components/chips/selected_user_chip';
+import {General} from '@constants';
+import {renderWithIntl} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import SelectedUserChipById from './selected_user_chip_by_id';
+
+// Mock the SelectedUserChip component
+jest.mock('@components/chips/selected_user_chip', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(SelectedUserChip).mockImplementation((props) => React.createElement('SelectedUserChip', {...props}));
+
+describe('SelectedUserChipById', () => {
+ function getBaseProps(): ComponentProps {
+ return {
+ onPress: jest.fn(),
+ teammateNameDisplay: General.TEAMMATE_NAME_DISPLAY.SHOW_USERNAME,
+ testID: 'test-selected-user-chip',
+ };
+ }
+
+ it('should render SelectedUserChip with correct props', () => {
+ const user = TestHelper.fakeUserModel({id: 'user-123'});
+ const props = getBaseProps();
+ props.user = user;
+
+ const {getByTestId} = renderWithIntl(
+ ,
+ );
+
+ const chip = getByTestId('test-selected-user-chip');
+ expect(chip).toHaveProp('user', user);
+ expect(chip).toHaveProp('onPress', props.onPress);
+ expect(chip).toHaveProp('teammateNameDisplay', props.teammateNameDisplay);
+ });
+
+ it('should handle missing user', () => {
+ const props = getBaseProps();
+
+ const {queryByTestId} = renderWithIntl(
+ ,
+ );
+
+ expect(queryByTestId('test-selected-user-chip')).toBeNull();
+ });
+});
diff --git a/app/components/chips/selected_user_chip_by_id/selected_user_chip_by_id.tsx b/app/components/chips/selected_user_chip_by_id/selected_user_chip_by_id.tsx
new file mode 100644
index 000000000..b40a18970
--- /dev/null
+++ b/app/components/chips/selected_user_chip_by_id/selected_user_chip_by_id.tsx
@@ -0,0 +1,35 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+import SelectedUserChip from '@components/chips/selected_user_chip';
+
+import type UserModel from '@typings/database/models/servers/user';
+
+type SelectedChipProps = {
+ user?: UserModel;
+ onPress: (id: string) => void;
+ testID?: string;
+ teammateNameDisplay: string;
+}
+
+export default function SelectedUserChipById({
+ testID,
+ user,
+ teammateNameDisplay,
+ onPress,
+}: SelectedChipProps) {
+ if (!user) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/app/components/chips/user_chip.test.tsx b/app/components/chips/user_chip.test.tsx
index f6d4c69d5..c690d1445 100644
--- a/app/components/chips/user_chip.test.tsx
+++ b/app/components/chips/user_chip.test.tsx
@@ -33,14 +33,14 @@ describe('UserChip', () => {
onPress={mockOnPress}
testID='user-chip'
teammateNameDisplay='username'
- showRemoveOption={true}
showAnimation={true}
+ actionIcon='remove'
/>,
);
const baseChip = getByTestId('user-chip');
expect(baseChip.props.label).toBe('test-user');
- expect(baseChip.props.showRemoveOption).toBe(true);
+ expect(baseChip.props.actionIcon).toBe('remove');
expect(baseChip.props.showAnimation).toBe(true);
expect(baseChip.props.prefix).toBeDefined();
diff --git a/app/components/chips/user_chip.tsx b/app/components/chips/user_chip.tsx
index eda03a302..d57f06298 100644
--- a/app/components/chips/user_chip.tsx
+++ b/app/components/chips/user_chip.tsx
@@ -16,7 +16,7 @@ type SelectedChipProps = {
onPress: (id: string) => void;
testID?: string;
teammateNameDisplay: string;
- showRemoveOption?: boolean;
+ actionIcon?: 'remove' | 'downArrow';
showAnimation?: boolean;
}
@@ -25,7 +25,7 @@ export default function UserChip({
user,
teammateNameDisplay,
onPress: receivedOnPress,
- showRemoveOption,
+ actionIcon,
showAnimation,
}: SelectedChipProps) {
const intl = useIntl();
@@ -49,7 +49,7 @@ export default function UserChip({
;
/**
* callback to set the value of showToast
@@ -165,17 +165,13 @@ export default function SelectedUsers({
const users = useMemo(() => {
const u = [];
- for (const user of Object.values(selectedIds)) {
- if (!user) {
- continue;
- }
-
- const userItemTestID = `${testID}.${user.id}`;
+ for (const userId of selectedIds) {
+ const userItemTestID = `${testID}.${userId}`;
u.push(
- void;
term: string;
- selectedIds: {[id: string]: UserProfile};
+ selectedIds: Set;
fetchFunction: (page: number) => Promise;
searchFunction: (term: string) => Promise;
createFilter: (exactMatches: UserProfile[], term: string) => ((p: UserProfile) => boolean);
testID: string;
location: AvailableScreens;
+ customSection?: (profiles: UserProfile[]) => Array>;
}
export default function ServerUserList({
@@ -35,6 +37,7 @@ export default function ServerUserList({
createFilter,
testID,
location,
+ customSection,
}: Props) {
const serverUrl = useServerUrl();
@@ -129,6 +132,7 @@ export default function ServerUserList({
tutorialWatched={tutorialWatched}
includeUserMargin={true}
location={location}
+ customSection={customSection}
/>
);
}
diff --git a/app/components/user_list/index.test.tsx b/app/components/user_list/index.test.tsx
index bb04456bd..564c4cce8 100644
--- a/app/components/user_list/index.test.tsx
+++ b/app/components/user_list/index.test.tsx
@@ -117,7 +117,7 @@ describe('components/channel_list_row', () => {
handleSelectProfile: jest.fn(),
fetchMore: jest.fn(),
loading: true,
- selectedIds: {},
+ selectedIds: new Set(),
showNoResults: true,
tutorialWatched: true,
term: 'some term',
diff --git a/app/components/user_list/index.tsx b/app/components/user_list/index.tsx
index afbe48576..1007ac213 100644
--- a/app/components/user_list/index.tsx
+++ b/app/components/user_list/index.tsx
@@ -179,12 +179,13 @@ type Props = {
manageMode?: boolean;
showManageMode?: boolean;
showNoResults: boolean;
- selectedIds: {[id: string]: UserProfile};
+ selectedIds: Set;
testID?: string;
term?: string;
tutorialWatched: boolean;
includeUserMargin?: boolean;
location: AvailableScreens;
+ customSection?: (profiles: UserProfile[]) => Array>;
}
export default function UserList({
@@ -203,6 +204,7 @@ export default function UserList({
tutorialWatched,
includeUserMargin,
location,
+ customSection,
}: Props) {
const intl = useIntl();
const theme = useTheme();
@@ -223,8 +225,11 @@ export default function UserList({
return createProfiles(profiles, channelMembers);
}
+ if (customSection) {
+ return customSection(profiles);
+ }
return createProfilesSections(intl, profiles, channelMembers);
- }, [channelMembers, intl, loading, profiles, term]);
+ }, [channelMembers, customSection, intl, loading, profiles, term]);
const openUserProfile = useCallback(async (profile: UserProfile | UserModel) => {
let user: UserModel;
@@ -246,8 +251,8 @@ export default function UserList({
const renderItem = useCallback(({item, index, section}: RenderItemType) => {
// The list will re-render when the selection changes because it's passed into the list as extraData
- const selected = Boolean(selectedIds[item.id]);
- const canAdd = Object.keys(selectedIds).length < General.MAX_USERS_IN_GM;
+ const selected = selectedIds.has(item.id);
+ const canAdd = selectedIds.size < General.MAX_USERS_IN_GM;
const isChAdmin = item.scheme_admin || false;
diff --git a/app/products/playbooks/actions/local/checklist.test.ts b/app/products/playbooks/actions/local/checklist.test.ts
index 009c74775..e8e49411f 100644
--- a/app/products/playbooks/actions/local/checklist.test.ts
+++ b/app/products/playbooks/actions/local/checklist.test.ts
@@ -5,7 +5,7 @@ import DatabaseManager from '@database/manager';
import {getPlaybookChecklistItemById} from '@playbooks/database/queries/item';
import TestHelper from '@test/test_helper';
-import {updateChecklistItem, setChecklistItemCommand} from './checklist';
+import {updateChecklistItem, setChecklistItemCommand, setAssignee} from './checklist';
import type ServerDataOperator from '@database/operator/server_data_operator';
@@ -124,3 +124,59 @@ describe('setChecklistItemCommand', () => {
expect(updated!.command).toBe('');
});
});
+
+describe('setAssignee', () => {
+ it('should handle not found database', async () => {
+ const {error} = await setAssignee('foo', 'itemid', 'user123');
+ expect(error).toBeTruthy();
+ expect((error as Error).message).toContain('foo database not found');
+ });
+
+ it('should handle item not found', async () => {
+ const {error} = await setAssignee(serverUrl, 'nonexistent', 'user123');
+ expect(error).toBe('Item not found: nonexistent');
+ });
+
+ 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 setAssignee(serverUrl, item.id, 'user123');
+ expect(error).toBeTruthy();
+
+ operator.database.write = originalWrite;
+ });
+
+ it('should successfully set assignee for existing item', async () => {
+ const checklistId = 'checklistid';
+ const item = TestHelper.createPlaybookItem(checklistId, 0);
+ await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false});
+
+ const assigneeId = 'user123';
+ const {data, error} = await setAssignee(serverUrl, item.id, assigneeId);
+ expect(error).toBeUndefined();
+ expect(data).toBe(true);
+
+ const updated = await getPlaybookChecklistItemById(operator.database, item.id);
+ expect(updated).toBeDefined();
+ expect(updated!.assigneeId).toBe(assigneeId);
+ });
+
+ it('should handle empty assignee ID', async () => {
+ const checklistId = 'checklistid';
+ const item = TestHelper.createPlaybookItem(checklistId, 0);
+ await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false});
+
+ const {data, error} = await setAssignee(serverUrl, item.id, '');
+ expect(error).toBeUndefined();
+ expect(data).toBe(true);
+
+ const updated = await getPlaybookChecklistItemById(operator.database, item.id);
+ expect(updated).toBeDefined();
+ expect(updated!.assigneeId).toBe('');
+ });
+});
diff --git a/app/products/playbooks/actions/local/checklist.ts b/app/products/playbooks/actions/local/checklist.ts
index ef5da60dd..4a8f38b95 100644
--- a/app/products/playbooks/actions/local/checklist.ts
+++ b/app/products/playbooks/actions/local/checklist.ts
@@ -46,3 +46,24 @@ export async function setChecklistItemCommand(serverUrl: string, itemId: string,
return {error};
}
}
+
+export async function setAssignee(serverUrl: string, itemId: string, assigneeId: string) {
+ try {
+ const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
+ const item = await getPlaybookChecklistItemById(database, itemId);
+ if (!item) {
+ return {error: `Item not found: ${itemId}`};
+ }
+
+ await database.write(async () => {
+ item.update((i) => {
+ i.assigneeId = assigneeId;
+ });
+ });
+
+ return {data: true};
+ } catch (error) {
+ logError('failed to update checklist item assignee', error);
+ return {error};
+ }
+}
diff --git a/app/products/playbooks/actions/local/run.test.ts b/app/products/playbooks/actions/local/run.test.ts
index d84dadf32..fd9f8a209 100644
--- a/app/products/playbooks/actions/local/run.test.ts
+++ b/app/products/playbooks/actions/local/run.test.ts
@@ -2,9 +2,13 @@
// 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 {handlePlaybookRuns} from './run';
+import {handlePlaybookRuns, setOwner} from './run';
+
+import type {Database} from '@nozbe/watermelondb';
+import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
const serverUrl = 'baseHandler.test.com';
@@ -45,3 +49,107 @@ describe('handlePlaybookRuns', () => {
expect(data![2]._preparedState).toBe('create');
});
});
+
+describe('setOwner', () => {
+ let database: Database;
+ beforeEach(() => {
+ database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
+ });
+
+ it('should set owner successfully when run exists', async () => {
+ // Create a playbook run
+ const runs = TestHelper.createPlaybookRuns(1, 0, 0);
+ await handlePlaybookRuns(serverUrl, runs, false, false);
+
+ const playbookRunId = runs[0].id;
+ const newOwnerId = 'new_owner_user_id';
+
+ const {data, error} = await setOwner(serverUrl, playbookRunId, newOwnerId);
+
+ expect(error).toBeUndefined();
+ expect(data).toBe(true);
+
+ // Verify the owner was actually updated in the database
+ const updatedRun = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId);
+ expect(updatedRun.ownerUserId).toBe(newOwnerId);
+ });
+
+ it('should return error when run is not found', async () => {
+ const nonExistentRunId = 'non_existent_run_id';
+ const newOwnerId = 'new_owner_user_id';
+
+ const {data, error} = await setOwner(serverUrl, nonExistentRunId, newOwnerId);
+
+ expect(error).toBe('Run not found');
+ expect(data).toBeUndefined();
+ });
+
+ it('should return error when database is not found', async () => {
+ const playbookRunId = 'playbook_run_1';
+ const newOwnerId = 'new_owner_user_id';
+
+ const {data, error} = await setOwner('non_existent_server', playbookRunId, newOwnerId);
+
+ expect(error).toBeDefined();
+ expect((error as Error).message).toContain('non_existent_server database not found');
+ expect(data).toBeUndefined();
+ });
+
+ it('should handle database write errors gracefully', async () => {
+ // Create a playbook run
+ const runs = TestHelper.createPlaybookRuns(1, 0, 0);
+ await handlePlaybookRuns(serverUrl, runs, false, false);
+
+ const playbookRunId = runs[0].id;
+ const newOwnerId = 'new_owner_user_id';
+
+ // Mock the database.write to throw an error
+ const originalWrite = database.write;
+ database.write = jest.fn().mockRejectedValue(new Error('Database write failed'));
+
+ const {data, error} = await setOwner(serverUrl, playbookRunId, newOwnerId);
+
+ expect(error).toBeDefined();
+ expect((error as Error).message).toBe('Database write failed');
+ expect(data).toBeUndefined();
+
+ // Restore the original method
+ database.write = originalWrite;
+ });
+
+ it('should update owner to the same user id without issues', async () => {
+ // Create a playbook run
+ const runs = TestHelper.createPlaybookRuns(1, 0, 0);
+ await handlePlaybookRuns(serverUrl, runs, false, false);
+
+ const playbookRunId = runs[0].id;
+ const sameOwnerId = runs[0].owner_user_id; // Use the same owner ID
+
+ const {data, error} = await setOwner(serverUrl, playbookRunId, sameOwnerId);
+
+ expect(error).toBeUndefined();
+ expect(data).toBe(true);
+
+ // Verify the owner remains the same
+ const updatedRun = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId);
+ expect(updatedRun.ownerUserId).toBe(sameOwnerId);
+ });
+
+ it('should handle empty string owner id', async () => {
+ // Create a playbook run
+ const runs = TestHelper.createPlaybookRuns(1, 0, 0);
+ await handlePlaybookRuns(serverUrl, runs, false, false);
+
+ const playbookRunId = runs[0].id;
+ const emptyOwnerId = '';
+
+ const {data, error} = await setOwner(serverUrl, playbookRunId, emptyOwnerId);
+
+ expect(error).toBeUndefined();
+ expect(data).toBe(true);
+
+ // Verify the owner was updated to empty string
+ const updatedRun = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId);
+ expect(updatedRun.ownerUserId).toBe(emptyOwnerId);
+ });
+});
diff --git a/app/products/playbooks/actions/local/run.ts b/app/products/playbooks/actions/local/run.ts
index e25bc576f..2b4b172df 100644
--- a/app/products/playbooks/actions/local/run.ts
+++ b/app/products/playbooks/actions/local/run.ts
@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
+import {getPlaybookRunById} from '@playbooks/database/queries/run';
import {logError} from '@utils/log';
export async function handlePlaybookRuns(serverUrl: string, runs: PlaybookRun[], prepareRecordsOnly = false, processChildren = false) {
@@ -18,3 +19,24 @@ export async function handlePlaybookRuns(serverUrl: string, runs: PlaybookRun[],
return {error};
}
}
+
+export async function setOwner(serverUrl: string, playbookRunId: string, ownerId: string) {
+ try {
+ const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
+ const run = await getPlaybookRunById(database, playbookRunId);
+ if (!run) {
+ return {error: 'Run not found'};
+ }
+
+ await database.write(async () => {
+ run.update((r) => {
+ r.ownerUserId = ownerId;
+ });
+ });
+
+ return {data: true};
+ } catch (error) {
+ logError('failed to set owner', error);
+ return {error};
+ }
+}
diff --git a/app/products/playbooks/actions/remote/checklist.test.ts b/app/products/playbooks/actions/remote/checklist.test.ts
index 261b888b3..aa80775b4 100644
--- a/app/products/playbooks/actions/remote/checklist.test.ts
+++ b/app/products/playbooks/actions/remote/checklist.test.ts
@@ -4,9 +4,20 @@
import DatabaseManager from '@database/manager';
import IntegrationsManager from '@managers/integrations_manager';
import NetworkManager from '@managers/network_manager';
-import {setChecklistItemCommand as localSetChecklistItemCommand, updateChecklistItem as localUpdateChecklistItem} from '@playbooks/actions/local/checklist';
+import {
+ setChecklistItemCommand as localSetChecklistItemCommand,
+ updateChecklistItem as localUpdateChecklistItem,
+ setAssignee as localSetAssignee,
+} from '@playbooks/actions/local/checklist';
-import {updateChecklistItem, runChecklistItem, skipChecklistItem, restoreChecklistItem, setChecklistItemCommand} from './checklist';
+import {
+ updateChecklistItem,
+ runChecklistItem,
+ skipChecklistItem,
+ restoreChecklistItem,
+ setChecklistItemCommand,
+ setAssignee,
+} from './checklist';
const serverUrl = 'baseHandler.test.com';
@@ -21,6 +32,7 @@ const mockClient = {
runChecklistItemSlashCommand: jest.fn(),
skipChecklistItem: jest.fn(),
restoreChecklistItem: jest.fn(),
+ setAssignee: jest.fn(),
setChecklistItemCommand: jest.fn(),
};
@@ -182,6 +194,46 @@ describe('checklist', () => {
});
});
+ describe('setAssignee', () => {
+ it('should handle client error', async () => {
+ jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
+
+ const result = await setAssignee(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, 'user-id-1');
+ expect(result.error).toBeDefined();
+ expect(mockClient.setAssignee).not.toHaveBeenCalled();
+ expect(localSetAssignee).not.toHaveBeenCalled();
+ });
+
+ it('should handle API exception', async () => {
+ mockClient.setAssignee.mockImplementationOnce(throwFunc);
+
+ const result = await setAssignee(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, 'user-id-1');
+ expect(result.error).toBeDefined();
+ expect(mockClient.setAssignee).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber, 'user-id-1');
+ expect(localSetAssignee).not.toHaveBeenCalled();
+ });
+
+ it('should set assignee successfully', async () => {
+ mockClient.setAssignee.mockResolvedValueOnce({});
+
+ const result = await setAssignee(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, 'user-id-1');
+ expect(result.data).toBe(true);
+ expect(result.error).toBeUndefined();
+ expect(mockClient.setAssignee).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber, 'user-id-1');
+ expect(localSetAssignee).toHaveBeenCalledWith(serverUrl, itemId, 'user-id-1');
+ });
+
+ it('should handle empty assignee ID', async () => {
+ mockClient.setAssignee.mockResolvedValueOnce({});
+
+ const result = await setAssignee(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, '');
+ expect(result.data).toBe(true);
+ expect(result.error).toBeUndefined();
+ expect(mockClient.setAssignee).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber, '');
+ expect(localSetAssignee).toHaveBeenCalledWith(serverUrl, itemId, '');
+ });
+ });
+
describe('setChecklistItemCommand', () => {
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
diff --git a/app/products/playbooks/actions/remote/checklist.ts b/app/products/playbooks/actions/remote/checklist.ts
index c04d6faa4..3d7bac71f 100644
--- a/app/products/playbooks/actions/remote/checklist.ts
+++ b/app/products/playbooks/actions/remote/checklist.ts
@@ -4,7 +4,11 @@
import {forceLogoutIfNecessary} from '@actions/remote/session';
import IntegrationsManager from '@managers/integrations_manager';
import NetworkManager from '@managers/network_manager';
-import {setChecklistItemCommand as localSetChecklistItemCommand, updateChecklistItem as localUpdateChecklistItem} from '@playbooks/actions/local/checklist';
+import {
+ setChecklistItemCommand as localSetChecklistItemCommand,
+ updateChecklistItem as localUpdateChecklistItem,
+ setAssignee as localSetAssignee,
+} from '@playbooks/actions/local/checklist';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
@@ -109,3 +113,24 @@ export const setChecklistItemCommand = async (
return {error};
}
};
+
+export const setAssignee = async (
+ serverUrl: string,
+ playbookRunId: string,
+ itemId: string,
+ checklistNumber: number,
+ itemNumber: number,
+ assigneeId: string,
+) => {
+ try {
+ const client = NetworkManager.getClient(serverUrl);
+ await client.setAssignee(playbookRunId, checklistNumber, itemNumber, assigneeId);
+
+ await localSetAssignee(serverUrl, itemId, assigneeId);
+ return {data: true};
+ } catch (error) {
+ logDebug('error on setAssignee', getFullErrorMessage(error));
+ forceLogoutIfNecessary(serverUrl, error);
+ return {error};
+ }
+};
diff --git a/app/products/playbooks/actions/remote/runs.test.ts b/app/products/playbooks/actions/remote/runs.test.ts
index 19bbb709e..d84b8bc2e 100644
--- a/app/products/playbooks/actions/remote/runs.test.ts
+++ b/app/products/playbooks/actions/remote/runs.test.ts
@@ -5,12 +5,12 @@ 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 {handlePlaybookRuns, setOwner as localSetOwner} from '@playbooks/actions/local/run';
import {getLastPlaybookRunsFetchAt} from '@playbooks/database/queries/run';
import EphemeralStore from '@store/ephemeral_store';
import TestHelper from '@test/test_helper';
-import {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel} from './runs';
+import {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel, setOwner} from './runs';
const serverUrl = 'baseHandler.test.com';
const channelId = 'channel-id-1';
@@ -20,6 +20,7 @@ const mockPlaybookRun2 = TestHelper.fakePlaybookRun({channel_id: channelId});
const mockClient = {
fetchPlaybookRuns: jest.fn(),
+ setOwner: jest.fn(),
};
jest.mock('@playbooks/database/queries/run');
@@ -212,3 +213,61 @@ describe('fetchFinishedRunsForChannel', () => {
});
});
});
+
+describe('setOwner', () => {
+ const playbookRunId = 'playbook-run-id-1';
+ const ownerId = 'owner-user-id-1';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.mocked(localSetOwner).mockResolvedValue({data: true});
+ });
+
+ it('should set owner successfully', async () => {
+ mockClient.setOwner.mockResolvedValueOnce(undefined);
+
+ const result = await setOwner(serverUrl, playbookRunId, ownerId);
+
+ expect(result).toBeDefined();
+ expect(result.error).toBeUndefined();
+ expect(result.data).toBe(true);
+ expect(mockClient.setOwner).toHaveBeenCalledWith(playbookRunId, ownerId);
+ expect(localSetOwner).toHaveBeenCalledWith(serverUrl, playbookRunId, ownerId);
+ });
+
+ it('should handle client error', async () => {
+ const clientError = new Error('Client error');
+ mockClient.setOwner.mockRejectedValueOnce(clientError);
+
+ const result = await setOwner(serverUrl, playbookRunId, ownerId);
+
+ expect(result).toBeDefined();
+ expect(result.error).toBeDefined();
+ expect(result.data).toBeUndefined();
+ expect(mockClient.setOwner).toHaveBeenCalledWith(playbookRunId, ownerId);
+ expect(localSetOwner).not.toHaveBeenCalled();
+ });
+
+ it('should handle network manager error', async () => {
+ jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
+
+ const result = await setOwner(serverUrl, playbookRunId, ownerId);
+
+ expect(result).toBeDefined();
+ expect(result.error).toBeDefined();
+ expect(result.data).toBeUndefined();
+ expect(localSetOwner).not.toHaveBeenCalled();
+ });
+
+ it('should handle empty string parameters', async () => {
+ mockClient.setOwner.mockResolvedValueOnce(undefined);
+
+ const result = await setOwner(serverUrl, '', '');
+
+ expect(result).toBeDefined();
+ expect(result.error).toBeUndefined();
+ expect(result.data).toBe(true);
+ expect(mockClient.setOwner).toHaveBeenCalledWith('', '');
+ expect(localSetOwner).toHaveBeenCalledWith(serverUrl, '', '');
+ });
+});
diff --git a/app/products/playbooks/actions/remote/runs.ts b/app/products/playbooks/actions/remote/runs.ts
index d8327ed6d..ec466f49f 100644
--- a/app/products/playbooks/actions/remote/runs.ts
+++ b/app/products/playbooks/actions/remote/runs.ts
@@ -6,7 +6,7 @@ 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 {handlePlaybookRuns, setOwner as localSetOwner} from '@playbooks/actions/local/run';
import {getLastPlaybookRunsFetchAt} from '@playbooks/database/queries/run';
import {getMaxRunUpdateAt} from '@playbooks/utils/run';
import EphemeralStore from '@store/ephemeral_store';
@@ -109,3 +109,17 @@ export const fetchPlaybookRun = async (serverUrl: string, runId: string, fetchOn
return {error};
}
};
+
+export const setOwner = async (serverUrl: string, playbookRunId: string, ownerId: string) => {
+ try {
+ const client = NetworkManager.getClient(serverUrl);
+ await client.setOwner(playbookRunId, ownerId);
+
+ await localSetOwner(serverUrl, playbookRunId, ownerId);
+ return {data: true};
+ } catch (error) {
+ logDebug('error on setOwner', getFullErrorMessage(error));
+ forceLogoutIfNecessary(serverUrl, error);
+ return {error};
+ }
+};
diff --git a/app/products/playbooks/client/rest.test.ts b/app/products/playbooks/client/rest.test.ts
index cb68336ae..94f7ae605 100644
--- a/app/products/playbooks/client/rest.test.ts
+++ b/app/products/playbooks/client/rest.test.ts
@@ -78,10 +78,9 @@ describe('setChecklistItemState', () => {
jest.mocked(client.doFetch).mockResolvedValue(mockResponse);
- const result = await client.setChecklistItemState(playbookRunID, checklistNum, itemNum, newState);
+ 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 () => {
@@ -89,13 +88,10 @@ describe('setChecklistItemState', () => {
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);
+ await expect(client.setChecklistItemState(playbookRunID, checklistNum, itemNum, newState)).rejects.toThrow('Network error');
});
test('should set checklist item state with empty state', async () => {
@@ -109,10 +105,9 @@ describe('setChecklistItemState', () => {
jest.mocked(client.doFetch).mockResolvedValue(mockResponse);
- const result = await client.setChecklistItemState(playbookRunID, checklistNum, itemNum, newState);
+ 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 () => {
@@ -126,10 +121,9 @@ describe('setChecklistItemState', () => {
jest.mocked(client.doFetch).mockResolvedValue(mockResponse);
- const result = await client.setChecklistItemState(playbookRunID, checklistNum, itemNum, newState);
+ await client.setChecklistItemState(playbookRunID, checklistNum, itemNum, newState);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
- expect(result).toEqual(mockResponse);
});
});
@@ -285,3 +279,115 @@ describe('setChecklistItemCommand', () => {
});
});
+describe('setOwner', () => {
+ test('should set owner successfully', async () => {
+ const playbookRunId = 'run123';
+ const ownerId = 'user456';
+ const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/owner`;
+ const expectedOptions = {method: 'post', body: {owner_id: ownerId}};
+ const mockResponse = {success: true};
+
+ jest.mocked(client.doFetch).mockResolvedValue(mockResponse);
+
+ const result = await client.setOwner(playbookRunId, ownerId);
+
+ expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
+ expect(result).toEqual(mockResponse);
+ });
+
+ test('should set owner with empty owner id', async () => {
+ const playbookRunId = 'run123';
+ const ownerId = '';
+ const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/owner`;
+ const expectedOptions = {method: 'post', body: {owner_id: ownerId}};
+ const mockResponse = {success: true};
+
+ jest.mocked(client.doFetch).mockResolvedValue(mockResponse);
+
+ const result = await client.setOwner(playbookRunId, ownerId);
+
+ expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
+ expect(result).toEqual(mockResponse);
+ });
+
+ test('should handle error when setting owner', async () => {
+ const playbookRunId = 'run123';
+ const ownerId = 'user456';
+
+ jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
+
+ await expect(client.setOwner(playbookRunId, ownerId)).rejects.toThrow('Network error');
+ });
+});
+
+describe('setAssignee', () => {
+ test('should set assignee successfully', async () => {
+ const playbookRunId = 'run123';
+ const checklistNum = 1;
+ const itemNum = 2;
+ const assigneeId = 'user789';
+ const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/item/${itemNum}/assignee`;
+ const expectedOptions = {method: 'put', body: {assignee_id: assigneeId}};
+
+ jest.mocked(client.doFetch).mockResolvedValue(undefined);
+
+ await client.setAssignee(playbookRunId, checklistNum, itemNum, assigneeId);
+
+ expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
+ });
+
+ test('should set assignee with zero indices', async () => {
+ const playbookRunId = 'run123';
+ const checklistNum = 0;
+ const itemNum = 0;
+ const assigneeId = 'user789';
+ const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/item/${itemNum}/assignee`;
+ const expectedOptions = {method: 'put', body: {assignee_id: assigneeId}};
+
+ jest.mocked(client.doFetch).mockResolvedValue(undefined);
+
+ await client.setAssignee(playbookRunId, checklistNum, itemNum, assigneeId);
+
+ expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
+ });
+
+ test('should set assignee with undefined assignee id', async () => {
+ const playbookRunId = 'run123';
+ const checklistNum = 1;
+ const itemNum = 2;
+ const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/item/${itemNum}/assignee`;
+ const expectedOptions = {method: 'put', body: {assignee_id: undefined}};
+
+ jest.mocked(client.doFetch).mockResolvedValue(undefined);
+
+ await client.setAssignee(playbookRunId, checklistNum, itemNum);
+
+ expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
+ });
+
+ test('should set assignee with empty assignee id', async () => {
+ const playbookRunId = 'run123';
+ const checklistNum = 1;
+ const itemNum = 2;
+ const assigneeId = '';
+ const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/item/${itemNum}/assignee`;
+ const expectedOptions = {method: 'put', body: {assignee_id: assigneeId}};
+
+ jest.mocked(client.doFetch).mockResolvedValue(undefined);
+
+ await client.setAssignee(playbookRunId, checklistNum, itemNum, assigneeId);
+
+ expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
+ });
+
+ test('should handle error when setting assignee', async () => {
+ const playbookRunId = 'run123';
+ const checklistNum = 1;
+ const itemNum = 2;
+ const assigneeId = 'user789';
+
+ jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
+
+ await expect(client.setAssignee(playbookRunId, checklistNum, itemNum, assigneeId)).rejects.toThrow('Network error');
+ });
+});
diff --git a/app/products/playbooks/client/rest.ts b/app/products/playbooks/client/rest.ts
index 86b5d8034..63f9d41d2 100644
--- a/app/products/playbooks/client/rest.ts
+++ b/app/products/playbooks/client/rest.ts
@@ -10,6 +10,7 @@ export interface ClientPlaybooksMix {
// Playbook Runs
fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise;
fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise;
+ setOwner: (playbookRunId: string, ownerId: string) => Promise;
// Run Management
// finishRun: (playbookRunId: string) => Promise;
@@ -18,6 +19,7 @@ export interface ClientPlaybooksMix {
setChecklistItemState: (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => Promise;
skipChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise;
restoreChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise;
+ setAssignee: (playbookRunId: string, checklistNum: number, itemNum: number, assigneeId?: string) => Promise;
// skipChecklist: (playbookRunID: string, checklistNum: number) => Promise;
@@ -62,6 +64,14 @@ const ClientPlaybooks = >(superclass: TBas
);
};
+ setOwner = async (playbookRunId: string, ownerId: string) => {
+ const data = await this.doFetch(
+ `${this.getPlaybookRunRoute(playbookRunId)}/owner`,
+ {method: 'post', body: {owner_id: ownerId}},
+ );
+ return data;
+ };
+
// Run Management
// finishRun = async (playbookRunId: string) => {
// try {
@@ -76,14 +86,10 @@ const ClientPlaybooks = >(superclass: TBas
// 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};
- }
+ await this.doFetch(
+ `${this.getPlaybookRunRoute(playbookRunID)}/checklists/${checklistNum}/item/${itemNum}/state`,
+ {method: 'put', body: {new_state: newState}},
+ );
};
skipChecklistItem = async (playbookRunID: string, checklistNum: number, itemNum: number) => {
@@ -107,6 +113,13 @@ const ClientPlaybooks = >(superclass: TBas
// );
// };
+ setAssignee = async (playbookRunId: string, checklistNum: number, itemNum: number, assigneeId?: string) => {
+ await this.doFetch(
+ `${this.getPlaybookRunRoute(playbookRunId)}/checklists/${checklistNum}/item/${itemNum}/assignee`,
+ {method: 'put', body: {assignee_id: assigneeId}},
+ );
+ };
+
// Slash Commands
runChecklistItemSlashCommand = async (playbookRunId: string, checklistNumber: number, itemNumber: number) => {
const data = await this.doFetch(
diff --git a/app/products/playbooks/constants/screens.ts b/app/products/playbooks/constants/screens.ts
index 7bd4ebdb7..76d4603b5 100644
--- a/app/products/playbooks/constants/screens.ts
+++ b/app/products/playbooks/constants/screens.ts
@@ -4,9 +4,11 @@
export const PLAYBOOKS_RUNS = 'PlaybookRuns';
export const PLAYBOOK_RUN = 'PlaybookRun';
export const PLAYBOOK_EDIT_COMMAND = 'PlaybookEditCommand';
+export const PLAYBOOK_SELECT_USER = 'PlaybookSelectUser';
export default {
PLAYBOOKS_RUNS,
PLAYBOOK_RUN,
PLAYBOOK_EDIT_COMMAND,
+ PLAYBOOK_SELECT_USER,
};
diff --git a/app/products/playbooks/database/models/playbook_run.ts b/app/products/playbooks/database/models/playbook_run.ts
index f88858d18..f71d3e7a2 100644
--- a/app/products/playbooks/database/models/playbook_run.ts
+++ b/app/products/playbooks/database/models/playbook_run.ts
@@ -1,7 +1,6 @@
// 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';
@@ -9,6 +8,7 @@ import {MM_TABLES} from '@constants/database';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import {safeParseJSONStringArray} from '@utils/helpers';
+import type {Query, Relation} from '@nozbe/watermelondb';
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';
@@ -137,11 +137,6 @@ export default class PlaybookRunModel extends Model implements PlaybookRunModelI
@children(PLAYBOOK_CHECKLIST) checklists!: Query;
- participants = (): Query => {
- const filteredParticipantIds = this.participantIds.filter((id) => id !== this.ownerUserId);
- return this.database.get(USER).query(Q.where('id', Q.oneOf(filteredParticipantIds)));
- };
-
prepareDestroyWithRelations = async (): Promise => {
const preparedModels: Model[] = [this.prepareDestroyPermanently()];
diff --git a/app/products/playbooks/database/queries/run.test.ts b/app/products/playbooks/database/queries/run.test.ts
index 592727926..44dc07cd3 100644
--- a/app/products/playbooks/database/queries/run.test.ts
+++ b/app/products/playbooks/database/queries/run.test.ts
@@ -1,8 +1,7 @@
// 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 {of as of$} from 'rxjs';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
@@ -15,6 +14,7 @@ import {
observePlaybookRunProgress,
getLastPlaybookRunsFetchAt,
queryParticipantsFromAPIRun,
+ observeParticipantsIdsFromPlaybookModel,
} from './run';
import type ServerDataOperator from '@database/operator/server_data_operator';
@@ -421,4 +421,208 @@ describe('Playbook Run Queries', () => {
expect(participantIds).not.toContain(ownerId);
});
});
+
+ describe('observeParticipantsIdsFromPlaybookModel', () => {
+ it('should return empty array when runModel is undefined', async () => {
+ const subscriptionNext = jest.fn();
+ const result = observeParticipantsIdsFromPlaybookModel(undefined);
+ result.subscribe({next: subscriptionNext});
+
+ expect(subscriptionNext).toHaveBeenCalledWith([]);
+ });
+
+ it('should return all participant IDs excluding owner when includeOwner is false (default)', async () => {
+ const subscriptionNext = jest.fn();
+ const ownerId = 'owner-user-id';
+ const participant1Id = 'participant-1-id';
+ const participant2Id = 'participant-2-id';
+ const participant3Id = 'participant-3-id';
+
+ const mockRunModel = TestHelper.fakePlaybookRunModel({
+ ownerUserId: ownerId,
+ participantIds: [ownerId, participant1Id, participant2Id, participant3Id],
+ });
+
+ // Mock the observe method to return the run model
+ mockRunModel.observe = jest.fn().mockReturnValue(
+ of$({
+ participantIds: [ownerId, participant1Id, participant2Id, participant3Id],
+ ownerUserId: ownerId,
+ }),
+ );
+
+ const result = observeParticipantsIdsFromPlaybookModel(mockRunModel);
+ result.subscribe({next: subscriptionNext});
+
+ expect(subscriptionNext).toHaveBeenCalledWith([participant1Id, participant2Id, participant3Id]);
+ });
+
+ it('should return all participant IDs including owner when includeOwner is true', async () => {
+ const subscriptionNext = jest.fn();
+ const ownerId = 'owner-user-id';
+ const participant1Id = 'participant-1-id';
+ const participant2Id = 'participant-2-id';
+ const participant3Id = 'participant-3-id';
+
+ const mockRunModel = TestHelper.fakePlaybookRunModel({
+ ownerUserId: ownerId,
+ participantIds: [ownerId, participant1Id, participant2Id, participant3Id],
+ });
+
+ // Mock the observe method to return the run model
+ mockRunModel.observe = jest.fn().mockReturnValue(
+ of$({
+ participantIds: [ownerId, participant1Id, participant2Id, participant3Id],
+ ownerUserId: ownerId,
+ }),
+ );
+
+ const result = observeParticipantsIdsFromPlaybookModel(mockRunModel, true);
+ result.subscribe({next: subscriptionNext});
+
+ expect(subscriptionNext).toHaveBeenCalledWith([ownerId, participant1Id, participant2Id, participant3Id]);
+ });
+
+ it('should return empty array when no participants exist and includeOwner is false', async () => {
+ const subscriptionNext = jest.fn();
+ const ownerId = 'owner-user-id';
+
+ const mockRunModel = TestHelper.fakePlaybookRunModel({
+ ownerUserId: ownerId,
+ participantIds: [ownerId], // Only owner
+ });
+
+ // Mock the observe method to return the run model
+ mockRunModel.observe = jest.fn().mockReturnValue(
+ of$({
+ participantIds: [ownerId],
+ ownerUserId: ownerId,
+ }),
+ );
+
+ const result = observeParticipantsIdsFromPlaybookModel(mockRunModel);
+ result.subscribe({next: subscriptionNext});
+
+ expect(subscriptionNext).toHaveBeenCalledWith([]);
+ });
+
+ it('should return only owner when no other participants exist and includeOwner is true', async () => {
+ const subscriptionNext = jest.fn();
+ const ownerId = 'owner-user-id';
+
+ const mockRunModel = TestHelper.fakePlaybookRunModel({
+ ownerUserId: ownerId,
+ participantIds: [ownerId], // Only owner
+ });
+
+ // Mock the observe method to return the run model
+ mockRunModel.observe = jest.fn().mockReturnValue(
+ of$({
+ participantIds: [ownerId],
+ ownerUserId: ownerId,
+ }),
+ );
+
+ const result = observeParticipantsIdsFromPlaybookModel(mockRunModel, true);
+ result.subscribe({next: subscriptionNext});
+
+ expect(subscriptionNext).toHaveBeenCalledWith([ownerId]);
+ });
+
+ it('should handle case where owner is not in participantIds and includeOwner is false', async () => {
+ const subscriptionNext = jest.fn();
+ const ownerId = 'owner-user-id';
+ const participant1Id = 'participant-1-id';
+ const participant2Id = 'participant-2-id';
+
+ const mockRunModel = TestHelper.fakePlaybookRunModel({
+ ownerUserId: ownerId,
+ participantIds: [participant1Id, participant2Id], // Owner not included
+ });
+
+ // Mock the observe method to return the run model
+ mockRunModel.observe = jest.fn().mockReturnValue(
+ of$({
+ participantIds: [participant1Id, participant2Id],
+ ownerUserId: ownerId,
+ }),
+ );
+
+ const result = observeParticipantsIdsFromPlaybookModel(mockRunModel);
+ result.subscribe({next: subscriptionNext});
+
+ expect(subscriptionNext).toHaveBeenCalledWith([participant1Id, participant2Id]);
+ });
+
+ it('should handle case where owner is not in participantIds and includeOwner is true', async () => {
+ const subscriptionNext = jest.fn();
+ const ownerId = 'owner-user-id';
+ const participant1Id = 'participant-1-id';
+ const participant2Id = 'participant-2-id';
+
+ const mockRunModel = TestHelper.fakePlaybookRunModel({
+ ownerUserId: ownerId,
+ participantIds: [participant1Id, participant2Id], // Owner not included
+ });
+
+ // Mock the observe method to return the run model
+ mockRunModel.observe = jest.fn().mockReturnValue(
+ of$({
+ participantIds: [participant1Id, participant2Id],
+ ownerUserId: ownerId,
+ }),
+ );
+
+ const result = observeParticipantsIdsFromPlaybookModel(mockRunModel, true);
+ result.subscribe({next: subscriptionNext});
+
+ expect(subscriptionNext).toHaveBeenCalledWith([participant1Id, participant2Id]);
+ });
+
+ it('should return empty array when participantIds is empty and includeOwner is false', async () => {
+ const subscriptionNext = jest.fn();
+ const ownerId = 'owner-user-id';
+
+ const mockRunModel = TestHelper.fakePlaybookRunModel({
+ ownerUserId: ownerId,
+ participantIds: [], // Empty participants
+ });
+
+ // Mock the observe method to return the run model
+ mockRunModel.observe = jest.fn().mockReturnValue(
+ of$({
+ participantIds: [],
+ ownerUserId: ownerId,
+ }),
+ );
+
+ const result = observeParticipantsIdsFromPlaybookModel(mockRunModel);
+ result.subscribe({next: subscriptionNext});
+
+ expect(subscriptionNext).toHaveBeenCalledWith([]);
+ });
+
+ it('should return empty array when participantIds is empty and includeOwner is true', async () => {
+ const subscriptionNext = jest.fn();
+ const ownerId = 'owner-user-id';
+
+ const mockRunModel = TestHelper.fakePlaybookRunModel({
+ ownerUserId: ownerId,
+ participantIds: [], // Empty participants
+ });
+
+ // Mock the observe method to return the run model
+ mockRunModel.observe = jest.fn().mockReturnValue(
+ of$({
+ participantIds: [],
+ ownerUserId: ownerId,
+ }),
+ );
+
+ const result = observeParticipantsIdsFromPlaybookModel(mockRunModel, true);
+ result.subscribe({next: subscriptionNext});
+
+ expect(subscriptionNext).toHaveBeenCalledWith([]);
+ });
+ });
});
diff --git a/app/products/playbooks/database/queries/run.ts b/app/products/playbooks/database/queries/run.ts
index fbcee14aa..c65ed6968 100644
--- a/app/products/playbooks/database/queries/run.ts
+++ b/app/products/playbooks/database/queries/run.ts
@@ -72,3 +72,19 @@ export const queryParticipantsFromAPIRun = (database: Database, run: PlaybookRun
const participantsIds = run.participant_ids.filter((id) => id !== run.owner_user_id);
return queryUsersById(database, participantsIds);
};
+
+const emptyParticipantsList: string[] = [];
+export const observeParticipantsIdsFromPlaybookModel = (runModel: PlaybookRunModel | undefined, includeOwner = false) => {
+ if (!runModel) {
+ return of$(emptyParticipantsList);
+ }
+ return runModel.observe().pipe(
+ switchMap((run) => {
+ let participantsIds = run.participantIds;
+ if (!includeOwner) {
+ participantsIds = participantsIds.filter((id) => id !== run.ownerUserId);
+ }
+ return of$(participantsIds);
+ }),
+ );
+};
diff --git a/app/products/playbooks/screens/index.test.tsx b/app/products/playbooks/screens/index.test.tsx
index 3d1c53190..2b2d12fa4 100644
--- a/app/products/playbooks/screens/index.test.tsx
+++ b/app/products/playbooks/screens/index.test.tsx
@@ -10,6 +10,7 @@ import {render} from '@test/intl-test-helper';
import EditCommand from './edit_command';
import PlaybookRun from './playbook_run';
import PlaybookRuns from './playbooks_runs';
+import SelectUser from './select_user';
import {loadPlaybooksScreen} from '.';
@@ -38,6 +39,12 @@ jest.mock('@playbooks/screens/edit_command', () => ({
}));
jest.mocked(EditCommand).mockImplementation((props) => {Screens.PLAYBOOK_EDIT_COMMAND});
+jest.mock('@playbooks/screens/select_user', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(SelectUser).mockImplementation((props) => {Screens.PLAYBOOK_SELECT_USER});
+
describe('Screen Registration', () => {
beforeEach(() => {
jest.clearAllMocks();
diff --git a/app/products/playbooks/screens/index.tsx b/app/products/playbooks/screens/index.tsx
index 5908f0162..576565267 100644
--- a/app/products/playbooks/screens/index.tsx
+++ b/app/products/playbooks/screens/index.tsx
@@ -12,6 +12,8 @@ export function loadPlaybooksScreen(screenName: string | number) {
return withServerDatabase(require('@playbooks/screens/playbook_run').default);
case Screens.PLAYBOOK_EDIT_COMMAND:
return withServerDatabase(require('@playbooks/screens/edit_command').default);
+ case Screens.PLAYBOOK_SELECT_USER:
+ return withServerDatabase(require('@playbooks/screens/select_user').default);
default:
return undefined;
}
diff --git a/app/products/playbooks/screens/navigation.test.ts b/app/products/playbooks/screens/navigation.test.ts
index ef3b41085..a155bf771 100644
--- a/app/products/playbooks/screens/navigation.test.ts
+++ b/app/products/playbooks/screens/navigation.test.ts
@@ -6,7 +6,7 @@ import {goToScreen} from '@screens/navigation';
import TestHelper from '@test/test_helper';
import {changeOpacity} from '@utils/theme';
-import {goToPlaybookRuns, goToPlaybookRun} from './navigation';
+import {goToPlaybookRuns, goToPlaybookRun, goToSelectUser} from './navigation';
jest.mock('@screens/navigation', () => ({
goToScreen: jest.fn(),
@@ -64,4 +64,28 @@ describe('Playbooks Navigation', () => {
);
});
});
+
+ describe('goToSelectUser', () => {
+ it('should navigate to select user screen with correct parameters when handleRemove is provided', async () => {
+ const title = 'Select User';
+ const participantIds = ['user1', 'user2', 'user3'];
+ const selected = 'user2';
+ const handleSelect = jest.fn();
+ const handleRemove = jest.fn();
+
+ await goToSelectUser(title, participantIds, selected, handleSelect, handleRemove);
+
+ expect(goToScreen).toHaveBeenCalledWith(
+ Screens.PLAYBOOK_SELECT_USER,
+ title,
+ {
+ participantIds,
+ selected,
+ handleSelect,
+ handleRemove,
+ },
+ {},
+ );
+ });
+ });
});
diff --git a/app/products/playbooks/screens/navigation.ts b/app/products/playbooks/screens/navigation.ts
index ec383fc3b..6a92dcef0 100644
--- a/app/products/playbooks/screens/navigation.ts
+++ b/app/products/playbooks/screens/navigation.ts
@@ -24,3 +24,18 @@ export async function goToPlaybookRun(intl: IntlShape, playbookRunId: string, pl
const title = intl.formatMessage({id: 'playbooks.playbook_run.title', defaultMessage: 'Playbook run'});
goToScreen(Screens.PLAYBOOK_RUN, title, {playbookRunId, playbookRun}, {});
}
+
+export async function goToSelectUser(
+ title: string,
+ participantIds: string[],
+ selected: string | undefined,
+ handleSelect: (user: UserProfile) => void,
+ handleRemove?: () => void,
+) {
+ goToScreen(Screens.PLAYBOOK_SELECT_USER, title, {
+ participantIds,
+ selected,
+ handleSelect,
+ handleRemove,
+ }, {});
+}
diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx
index 8be08e889..b261cf167 100644
--- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx
+++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx
@@ -2,17 +2,19 @@
// See LICENSE.txt for license information.
import {BottomSheetScrollView} from '@gorhom/bottom-sheet';
-import {act, fireEvent} from '@testing-library/react-native';
+import {act, fireEvent, waitFor} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import {ScrollView} from 'react-native';
import OptionBox from '@components/option_box';
import OptionItem from '@components/option_item';
import {useIsTablet} from '@hooks/device';
-import {setChecklistItemCommand} from '@playbooks/actions/remote/checklist';
+import {setAssignee, setChecklistItemCommand} from '@playbooks/actions/remote/checklist';
+import {goToSelectUser} from '@playbooks/screens/navigation';
import {dismissBottomSheet, goToScreen, openUserProfileModal} from '@screens/navigation';
import {renderWithIntl} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
+import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import ChecklistItemBottomSheet from './checklist_item_bottom_sheet';
@@ -33,12 +35,15 @@ jest.mocked(OptionBox).mockImplementation((props) => React.createElement('Option
jest.mock('@components/option_item');
jest.mocked(OptionItem).mockImplementation((props) => React.createElement('OptionItem', props));
+jest.mock('@playbooks/screens/navigation');
jest.mock('@playbooks/actions/remote/checklist');
jest.mock('@context/server', () => ({
useServerUrl: jest.fn().mockReturnValue('server-url'),
}));
+jest.mock('@utils/snack_bar');
+
describe('ChecklistItemBottomSheet', () => {
const mockOnCheck = jest.fn();
const mockOnSkip = jest.fn();
@@ -85,6 +90,7 @@ describe('ChecklistItemBottomSheet', () => {
onRunCommand: mockOnRunCommand,
teammateNameDisplay: mockTeammateNameDisplay,
isDisabled: false,
+ participantIds: ['user-1', 'user-2'],
};
}
@@ -345,6 +351,27 @@ describe('ChecklistItemBottomSheet', () => {
expect(commandItem.props.info).toBe('None');
});
+ it('user profile option item is disabled when isDisabled is true', () => {
+ const props = getBaseProps();
+ props.isDisabled = true;
+ props.item = TestHelper.fakePlaybookChecklistItemModel({
+ ...props.item,
+ assigneeId: 'user-1',
+ });
+ const {getByTestId, rerender} = renderWithIntl();
+
+ let assigneeItem = getByTestId('checklist_item.assignee');
+ expect(assigneeItem).toHaveProp('type', 'none');
+ expect(assigneeItem).toHaveProp('action', undefined);
+
+ props.isDisabled = false;
+ rerender();
+
+ assigneeItem = getByTestId('checklist_item.assignee');
+ expect(assigneeItem).toHaveProp('type', 'arrow');
+ expect(assigneeItem).toHaveProp('action', expect.any(Function));
+ });
+
it('opens the command modal when the command is clicked', async () => {
const props = getBaseProps();
props.checklistNumber = 2;
@@ -402,13 +429,100 @@ describe('ChecklistItemBottomSheet', () => {
);
});
+ it('opens the select assigneed screen when the assignee option item is pressed', async () => {
+ const props = getBaseProps();
+ props.checklistNumber = 2;
+ props.itemNumber = 4;
+ const {getByTestId} = renderWithIntl();
+
+ const assigneeItem = getByTestId('checklist_item.assignee');
+ const onPress = assigneeItem.props.action;
+
+ await act(async () => {
+ onPress('user-1');
+ });
+
+ expect(goToSelectUser).toHaveBeenCalledWith(
+ 'Assignee',
+ ['user-1', 'user-2'],
+ 'user-1',
+ expect.any(Function),
+ expect.any(Function),
+ );
+
+ const handleSelect = jest.mocked(goToSelectUser).mock.calls[0][3];
+ const handleRemove = jest.mocked(goToSelectUser).mock.calls[0][4];
+
+ await act(async () => {
+ handleSelect(TestHelper.fakeUser({id: 'user-1'}));
+ });
+
+ expect(setAssignee).toHaveBeenCalledWith(
+ 'server-url',
+ 'run-1',
+ 'item-1',
+ 2,
+ 4,
+ 'user-1',
+ );
+
+ jest.mocked(setAssignee).mockClear();
+
+ await act(async () => {
+ handleRemove?.();
+ });
+
+ expect(setAssignee).toHaveBeenCalledWith(
+ 'server-url',
+ 'run-1',
+ 'item-1',
+ 2,
+ 4,
+ '',
+ );
+ });
+
+ it('handles set assignee error', async () => {
+ const props = getBaseProps();
+ jest.mocked(setAssignee).mockResolvedValue({error: 'error'});
+ const {getByTestId} = renderWithIntl();
+
+ const assigneeItem = getByTestId('checklist_item.assignee');
+ const onPress = assigneeItem.props.action;
+
+ await act(async () => {
+ onPress('user-1');
+ });
+
+ const handleSelect = jest.mocked(goToSelectUser).mock.calls[0][3];
+ const handleRemove = jest.mocked(goToSelectUser).mock.calls[0][4];
+
+ await act(async () => {
+ handleSelect(TestHelper.fakeUser({id: 'user-1'}));
+ });
+
+ await waitFor(() => {
+ expect(showPlaybookErrorSnackbar).toHaveBeenCalled();
+ });
+
+ jest.mocked(showPlaybookErrorSnackbar).mockClear();
+
+ await act(async () => {
+ handleRemove?.();
+ });
+
+ await waitFor(() => {
+ expect(showPlaybookErrorSnackbar).toHaveBeenCalled();
+ });
+ });
+
it('displays correct assignee label and icon', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl();
const assigneeItem = getByTestId('checklist_item.assignee');
expect(assigneeItem.props.label).toBe('Assignee');
- expect(assigneeItem.props.icon).toBe('account-multiple-plus-outline');
+ expect(assigneeItem.props.icon).toBe('account-plus-outline');
});
it('displays correct due date label and icon', () => {
diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx
index 73c6104ef..963e3a511 100644
--- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx
+++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx
@@ -10,9 +10,11 @@ import OptionBox from '@components/option_box';
import OptionItem, {ITEM_HEIGHT} from '@components/option_item';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
-import {setChecklistItemCommand} from '@playbooks/actions/remote/checklist';
+import {setAssignee, setChecklistItemCommand} from '@playbooks/actions/remote/checklist';
+import {goToSelectUser} from '@playbooks/screens/navigation';
import {dismissBottomSheet, goToScreen, openUserProfileModal} from '@screens/navigation';
import {toMilliseconds} from '@utils/datetime';
+import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
@@ -125,6 +127,7 @@ type Props = {
onRunCommand: () => void;
teammateNameDisplay: string;
isDisabled: boolean;
+ participantIds: string[];
};
function getDueDateInfo(intl: IntlShape, dueDate: number | undefined) {
@@ -152,6 +155,7 @@ const ChecklistItemBottomSheet = ({
onRunCommand,
teammateNameDisplay,
isDisabled,
+ participantIds,
}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
@@ -244,14 +248,39 @@ const ChecklistItemBottomSheet = ({
};
}, [assignee, intl, onUserChipPress, teammateNameDisplay]);
+ const handleSelect = useCallback(async (selected: UserProfile) => {
+ const res = await setAssignee(serverUrl, runId, item.id, checklistNumber, itemNumber, selected.id);
+ if (res.error) {
+ showPlaybookErrorSnackbar();
+ }
+ }, [checklistNumber, item.id, itemNumber, runId, serverUrl]);
+
+ const handleRemove = useCallback(async () => {
+ const res = await setAssignee(serverUrl, runId, item.id, checklistNumber, itemNumber, '');
+ if (res.error) {
+ showPlaybookErrorSnackbar();
+ }
+ }, [checklistNumber, item.id, itemNumber, runId, serverUrl]);
+
+ const openUserSelector = useCallback(() => {
+ goToSelectUser(
+ intl.formatMessage(messages.assignee),
+ participantIds,
+ assignee?.id,
+ handleSelect,
+ handleRemove,
+ );
+ }, [assignee?.id, handleRemove, handleSelect, intl, participantIds]);
+
const renderTaskDetails = () => (
{
});
}
+ async function addRunToDatabase(run: PartialPlaybookRun) {
+ await operator.handlePlaybookRun({
+ prepareRecordsOnly: false,
+ runs: [run],
+ });
+ }
+
async function addUserToDatabase(user: UserProfile) {
await operator.handleUsers({
prepareRecordsOnly: false,
@@ -74,12 +81,15 @@ describe('ChecklistItemBottomSheet Enhanced Component', () => {
describe('with database model item', () => {
it('should render enhanced component with database model item', async () => {
const rawItem = TestHelper.fakePlaybookChecklistItem('checklist-id', {id: 'item-1', assignee_id: 'user-1'});
+ const rawRun = TestHelper.fakePlaybookRun({id: 'run-1', participant_ids: ['user-1', 'user-2']});
+ await addRunToDatabase(rawRun);
await addItemToDatabase(rawItem);
await addUserToDatabase(TestHelper.fakeUser({id: 'user-1'}));
const item = await getPlaybookChecklistItemById(database, rawItem.id);
const props = getBaseProps();
+ props.runId = rawRun.id;
props.item = item!;
const {getByTestId} = renderWithEverything(, {database});
@@ -88,6 +98,24 @@ describe('ChecklistItemBottomSheet Enhanced Component', () => {
expect(bottomSheet).toBeTruthy();
expect(bottomSheet).toHaveProp('item', item);
expect(bottomSheet).toHaveProp('assignee', expect.objectContaining({id: 'user-1'}));
+ expect(bottomSheet).toHaveProp('participantIds', ['user-1', 'user-2']);
+ });
+
+ it('should handle missing run correctly', async () => {
+ const rawItem = TestHelper.fakePlaybookChecklistItem('checklist-id', {id: 'item-1', assignee_id: 'user-1'});
+ await addItemToDatabase(rawItem);
+
+ const item = await getPlaybookChecklistItemById(database, rawItem.id);
+
+ const props = getBaseProps();
+ props.runId = 'missing-run-id';
+ props.item = item!;
+
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const bottomSheet = getByTestId('checklist_item_bottom_sheet');
+ expect(bottomSheet).toBeTruthy();
+ expect(bottomSheet).toHaveProp('participantIds', []);
});
it('should handle assigneeId changes through observable', async () => {
@@ -159,6 +187,7 @@ describe('ChecklistItemBottomSheet Enhanced Component', () => {
expect(bottomSheet).toBeTruthy();
expect(bottomSheet).toHaveProp('item', apiItem);
expect(bottomSheet).toHaveProp('assignee', expect.objectContaining({id: 'user-1'}));
+ expect(bottomSheet).toHaveProp('participantIds', []);
});
it('should handle assigneeId changes through observable', async () => {
diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.ts b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.ts
index 10e0bd544..d8899ecd7 100644
--- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.ts
+++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.ts
@@ -4,6 +4,7 @@
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$, switchMap} from 'rxjs';
+import {observeParticipantsIdsFromPlaybookModel, observePlaybookRunById} from '@playbooks/database/queries/run';
import {observeUser} from '@queries/servers/user';
import ChecklistItemBottomSheet, {BOTTOM_SHEET_HEIGHT} from './checklist_item_bottom_sheet';
@@ -13,9 +14,10 @@ import type {WithDatabaseArgs} from '@typings/database/database';
type OwnProps = {
item: PlaybookChecklistItemModel | PlaybookChecklistItem;
+ runId: string;
} & WithDatabaseArgs;
-const enhanced = withObservables(['item'], ({item, database}: OwnProps) => {
+const enhanced = withObservables(['item', 'runId'], ({item, runId, database}: OwnProps) => {
if ('observe' in item) {
const observedItem = item.observe();
@@ -33,6 +35,9 @@ const enhanced = withObservables(['item'], ({item, database}: OwnProps) => {
return {
item: observedItem,
assignee,
+ participantIds: observePlaybookRunById(database, runId).pipe(
+ switchMap((run) => observeParticipantsIdsFromPlaybookModel(run, true)),
+ ),
};
}
@@ -41,6 +46,7 @@ const enhanced = withObservables(['item'], ({item, database}: OwnProps) => {
return {
item: of$(item),
assignee,
+ participantIds: of$([]),
};
});
diff --git a/app/products/playbooks/screens/playbook_run/index.ts b/app/products/playbooks/screens/playbook_run/index.ts
index a646852e8..c62418c70 100644
--- a/app/products/playbooks/screens/playbook_run/index.ts
+++ b/app/products/playbooks/screens/playbook_run/index.ts
@@ -7,15 +7,14 @@ import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {queryPlaybookChecklistByRun} from '@playbooks/database/queries/checklist';
import {queryPlaybookChecklistItemsByChecklists} from '@playbooks/database/queries/item';
-import {observePlaybookRunById, queryParticipantsFromAPIRun} from '@playbooks/database/queries/run';
+import {observeParticipantsIdsFromPlaybookModel, observePlaybookRunById, queryParticipantsFromAPIRun} from '@playbooks/database/queries/run';
import {areItemsOrdersEqual} from '@playbooks/utils/items_order';
import {isOverdue} from '@playbooks/utils/run';
import {observeCurrentUserId} from '@queries/servers/system';
-import {observeTeammateNameDisplay, observeUser} from '@queries/servers/user';
+import {observeTeammateNameDisplay, observeUser, queryUsersById} from '@queries/servers/user';
import PlaybookRun from './playbook_run';
-import type {UserModel} from '@database/models/server';
import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist';
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
import type {WithDatabaseArgs} from '@typings/database/database';
@@ -38,7 +37,6 @@ const getIds = (checklists: PlaybookChecklistModel[]) => {
return checklists.map((c) => c.id);
};
-const emptyParticipantsList: UserModel[] = [];
const enhanced = withObservables(['playbookRunId', 'playbookRun'], ({playbookRunId, playbookRun: providedRun, database}: OwnProps) => {
// We receive a API run instead of a model from the database
if (providedRun) {
@@ -61,10 +59,11 @@ const enhanced = withObservables(['playbookRunId', 'playbookRun'], ({playbookRun
// We only receive the id, so it should be a model from the database
const playbookRun = observePlaybookRunById(database, playbookRunId);
const owner = playbookRun.pipe(
- switchMap((r) => (r ? r.owner.observe() : of$(undefined))),
+ switchMap((r) => (r ? observeUser(database, r.ownerUserId) : of$(undefined))),
);
const participants = playbookRun.pipe(
- switchMap((r) => (r ? r.participants().observe() : of$(emptyParticipantsList))),
+ switchMap((r) => observeParticipantsIdsFromPlaybookModel(r, false)),
+ switchMap((ids) => queryUsersById(database, ids).observe()),
);
const checklists = queryPlaybookChecklistByRun(database, playbookRunId).observe();
diff --git a/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx b/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx
index e16f14c4a..b7aa27fb5 100644
--- a/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx
+++ b/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx
@@ -8,9 +8,13 @@ import UserAvatarsStack from '@components/user_avatars_stack';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import DatabaseManager from '@database/manager';
+import {setOwner} from '@playbooks/actions/remote/runs';
import {openUserProfileModal} from '@screens/navigation';
-import {renderWithEverything} from '@test/intl-test-helper';
+import {renderWithEverything, waitFor} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
+import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
+
+import {goToSelectUser} from '../navigation';
import ChecklistList from './checklist_list';
import ErrorState from './error_state';
@@ -22,6 +26,9 @@ import type {Database} from '@nozbe/watermelondb';
import type {PlaybookRunModel} from '@playbooks/database/models';
const serverUrl = 'some.server.url';
+
+jest.mock('@utils/snack_bar');
+
jest.mock('@context/server');
jest.mocked(useServerUrl).mockReturnValue(serverUrl);
@@ -55,6 +62,14 @@ jest.mocked(StatusUpdateIndicator).mockImplementation(
(props) => React.createElement('StatusUpdateIndicator', {testID: 'status-update-indicator', ...props}),
);
+jest.mock('../navigation', () => ({
+ goToSelectUser: jest.fn(),
+}));
+
+jest.mock('@playbooks/actions/remote/runs', () => ({
+ setOwner: jest.fn(),
+}));
+
describe('PlaybookRun', () => {
let database: Database;
@@ -159,6 +174,7 @@ describe('PlaybookRun', () => {
expect(ownerChip.props.user).toBe(props.owner);
expect(ownerChip.props.onPress).toBeDefined();
expect(ownerChip.props.teammateNameDisplay).toBe(General.TEAMMATE_NAME_DISPLAY.SHOW_USERNAME);
+ expect(ownerChip.props.actionIcon).toBe(undefined);
ownerChip.props.onPress();
expect(openUserProfileModal).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
@@ -191,6 +207,51 @@ describe('PlaybookRun', () => {
expect(queryByText('Participants')).toBeNull();
});
+ it('handles owner chip action when not read only', () => {
+ const props = getBaseProps();
+ props.participants.push(TestHelper.fakeUserModel({id: props.currentUserId}));
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const ownerChip = getByTestId('user-chip');
+ expect(ownerChip).toHaveProp('actionIcon', 'downArrow');
+ ownerChip.props.onPress();
+
+ expect(goToSelectUser).toHaveBeenCalledWith(
+ 'Owner',
+ [...props.participants.map((p) => p.id), props.owner!.id],
+ props.owner!.id,
+ expect.any(Function),
+ );
+ expect(openUserProfileModal).not.toHaveBeenCalled();
+
+ const handleSelect = jest.mocked(goToSelectUser).mock.calls[0][3];
+ handleSelect(TestHelper.fakeUser({id: 'user-2'}));
+
+ expect(setOwner).toHaveBeenCalledWith(
+ serverUrl,
+ props.playbookRun!.id,
+ 'user-2',
+ );
+ expect(showPlaybookErrorSnackbar).not.toHaveBeenCalled();
+ });
+
+ it('handles set owner error', async () => {
+ const props = getBaseProps();
+ props.participants.push(TestHelper.fakeUserModel({id: props.currentUserId}));
+ jest.mocked(setOwner).mockResolvedValue({error: 'error'});
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const ownerChip = getByTestId('user-chip');
+ ownerChip.props.onPress();
+
+ const handleSelect = jest.mocked(goToSelectUser).mock.calls[0][3];
+ handleSelect(TestHelper.fakeUser({id: 'user-2'}));
+
+ await waitFor(() => {
+ expect(showPlaybookErrorSnackbar).toHaveBeenCalled();
+ });
+ });
+
it('renders checklist list correctly', () => {
const props = getBaseProps();
const {getByTestId, getByText, queryByText, rerender} = renderWithEverything(, {database});
diff --git a/app/products/playbooks/screens/playbook_run/playbook_run.tsx b/app/products/playbooks/screens/playbook_run/playbook_run.tsx
index ae03bb0bc..3849ee1a7 100644
--- a/app/products/playbooks/screens/playbook_run/playbook_run.tsx
+++ b/app/products/playbooks/screens/playbook_run/playbook_run.tsx
@@ -13,12 +13,16 @@ import UserAvatarsStack from '@components/user_avatars_stack';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
+import {setOwner} from '@playbooks/actions/remote/runs';
import {getRunScheduledTimestamp, isRunFinished} from '@playbooks/utils/run';
import {openUserProfileModal, popTopScreen} from '@screens/navigation';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
+import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
+import {goToSelectUser} from '../navigation';
+
import ChecklistList from './checklist_list';
import ErrorState from './error_state';
import OutOfDateHeader from './out_of_date_header';
@@ -157,6 +161,9 @@ export default function PlaybookRun({
const isParticipant = participants.some((p) => p.id === currentUserId) || owner?.id === currentUserId;
+ const isFinished = isRunFinished(playbookRun);
+ const readOnly = isFinished || !isParticipant;
+
const containerStyle = useMemo(() => {
return [
styles.container,
@@ -176,12 +183,34 @@ export default function PlaybookRun({
});
}, [owner, intl, theme, channelId, componentId]);
+ const handleSelectOwner = useCallback(async (selected: UserProfile) => {
+ if (!playbookRun) {
+ return;
+ }
+
+ const res = await setOwner(serverUrl, playbookRun.id, selected.id);
+ if (res.error) {
+ showPlaybookErrorSnackbar();
+ }
+ }, [playbookRun, serverUrl]);
+
+ const openChangeOwnerModal = useCallback(() => {
+ if (!owner) {
+ return;
+ }
+
+ goToSelectUser(
+ intl.formatMessage(messages.owner),
+ [...participants.map((p) => p.id), owner?.id || ''],
+ owner?.id,
+ handleSelectOwner,
+ );
+ }, [handleSelectOwner, intl, owner, participants]);
+
if (!playbookRun) {
return ;
}
- const isFinished = isRunFinished(playbookRun);
-
return (
<>
diff --git a/app/products/playbooks/screens/playbooks_runs/playbook_card/index.ts b/app/products/playbooks/screens/playbooks_runs/playbook_card/index.ts
index 46ea03787..662fc354c 100644
--- a/app/products/playbooks/screens/playbooks_runs/playbook_card/index.ts
+++ b/app/products/playbooks/screens/playbooks_runs/playbook_card/index.ts
@@ -2,11 +2,11 @@
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
-import {of as of$} from 'rxjs';
+import {of as of$, switchMap} from 'rxjs';
-import {observePlaybookRunProgress, queryParticipantsFromAPIRun} from '@playbooks/database/queries/run';
+import {observeParticipantsIdsFromPlaybookModel, observePlaybookRunProgress, queryParticipantsFromAPIRun} from '@playbooks/database/queries/run';
import {getProgressFromRun} from '@playbooks/utils/progress';
-import {observeUser} from '@queries/servers/user';
+import {observeUser, queryUsersById} from '@queries/servers/user';
import PlaybookCard, {ITEM_HEIGHT} from './playbook_card';
@@ -18,10 +18,12 @@ type OwnProps = {
} & WithDatabaseArgs;
const enhanced = withObservables(['run'], ({run, database}: OwnProps) => {
- if ('participants' in run) {
+ if ('observe' in run) {
return {
run: run.observe(),
- participants: run.participants().observe(),
+ participants: observeParticipantsIdsFromPlaybookModel(run, false).pipe(
+ switchMap((ids) => queryUsersById(database, ids).observe()),
+ ),
progress: observePlaybookRunProgress(database, run.id),
owner: observeUser(database, run.ownerUserId),
};
diff --git a/app/products/playbooks/screens/select_user/index.test.tsx b/app/products/playbooks/screens/select_user/index.test.tsx
new file mode 100644
index 000000000..f87c1bbcd
--- /dev/null
+++ b/app/products/playbooks/screens/select_user/index.test.tsx
@@ -0,0 +1,172 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {type ComponentProps} from 'react';
+
+import {SYSTEM_IDENTIFIERS} from '@constants/database';
+import DatabaseManager from '@database/manager';
+import {act, renderWithEverything, waitFor} from '@test/intl-test-helper';
+
+import SelectUserComponent from './select_user';
+
+import SelectUser from './';
+
+import type ServerDataOperator from '@database/operator/server_data_operator';
+import type {Database} from '@nozbe/watermelondb';
+
+jest.mock('./select_user');
+jest.mocked(SelectUserComponent).mockImplementation(
+ (props) => React.createElement('SelectUser', {testID: 'select-user', ...props}),
+);
+
+const serverUrl = 'server-url';
+
+describe('SelectUser', () => {
+ function getBaseProps(): ComponentProps {
+ return {
+ handleSelect: jest.fn(),
+ handleRemove: jest.fn(),
+ selected: 'selected-user-id',
+ componentId: 'PlaybookSelectUser',
+ participantIds: ['participant-id'],
+ };
+ }
+
+ let database: Database;
+ let operator: ServerDataOperator;
+
+ beforeEach(async () => {
+ await DatabaseManager.init([serverUrl]);
+ const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
+ database = serverDatabaseAndOperator.database;
+ operator = serverDatabaseAndOperator.operator;
+ });
+
+ afterEach(() => {
+ DatabaseManager.destroyServerDatabase(serverUrl);
+ jest.clearAllMocks();
+ });
+
+ it('should render correctly with no data', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const selectUser = getByTestId('select-user');
+
+ // Default values from observables when no data exists
+ expect(selectUser.props.currentUserId).toBe('');
+ expect(selectUser.props.currentTeamId).toBe('');
+ });
+
+ it('should render correctly with current user data', async () => {
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
+ value: 'current-user-id',
+ }],
+ prepareRecordsOnly: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const selectUser = getByTestId('select-user');
+ expect(selectUser.props.currentUserId).toBe('current-user-id');
+ expect(selectUser.props.currentTeamId).toBe('');
+ });
+
+ it('should render correctly with current team data', async () => {
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
+ value: 'current-team-id',
+ }],
+ prepareRecordsOnly: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const selectUser = getByTestId('select-user');
+ expect(selectUser.props.currentTeamId).toBe('current-team-id');
+ expect(selectUser.props.currentUserId).toBe('');
+ });
+
+ it('should render correctly with both current user and team data', async () => {
+ await operator.handleSystem({
+ systems: [
+ {
+ id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
+ value: 'current-user-id',
+ },
+ {
+ id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
+ value: 'current-team-id',
+ },
+ ],
+ prepareRecordsOnly: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const selectUser = getByTestId('select-user');
+ expect(selectUser.props.currentUserId).toBe('current-user-id');
+ expect(selectUser.props.currentTeamId).toBe('current-team-id');
+ });
+
+ it('should update observables when data changes', async () => {
+ await operator.handleSystem({
+ systems: [
+ {
+ id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
+ value: 'current-user-id',
+ },
+ {
+ id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
+ value: 'current-team-id',
+ },
+ ],
+ prepareRecordsOnly: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const selectUser = getByTestId('select-user');
+ expect(selectUser.props.currentUserId).toBe('current-user-id');
+ expect(selectUser.props.currentTeamId).toBe('current-team-id');
+
+ await act(async () => {
+ // Update current user ID
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
+ value: 'new-user-id',
+ }],
+ prepareRecordsOnly: false,
+ });
+ });
+
+ await waitFor(() => {
+ expect(selectUser.props.currentUserId).toBe('new-user-id');
+ expect(selectUser.props.currentTeamId).toBe('current-team-id');
+ });
+
+ await act(async () => {
+ // Update current team ID
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
+ value: 'new-team-id',
+ }],
+ prepareRecordsOnly: false,
+ });
+ });
+
+ await waitFor(() => {
+ expect(selectUser.props.currentUserId).toBe('new-user-id');
+ expect(selectUser.props.currentTeamId).toBe('new-team-id');
+ });
+ });
+});
diff --git a/app/products/playbooks/screens/select_user/index.ts b/app/products/playbooks/screens/select_user/index.ts
new file mode 100644
index 000000000..fad44e936
--- /dev/null
+++ b/app/products/playbooks/screens/select_user/index.ts
@@ -0,0 +1,16 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
+
+import {observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
+
+import SelectUser from './select_user';
+
+import type {WithDatabaseArgs} from '@typings/database/database';
+
+const withTeamId = withObservables([], ({database}: WithDatabaseArgs) => ({
+ currentUserId: observeCurrentUserId(database),
+ currentTeamId: observeCurrentTeamId(database),
+}));
+
+export default withDatabase(withTeamId(SelectUser));
diff --git a/app/products/playbooks/screens/select_user/select_user.test.tsx b/app/products/playbooks/screens/select_user/select_user.test.tsx
new file mode 100644
index 000000000..6abb6d392
--- /dev/null
+++ b/app/products/playbooks/screens/select_user/select_user.test.tsx
@@ -0,0 +1,308 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {type ComponentProps} from 'react';
+
+import {fetchProfilesInTeam, searchProfiles} from '@actions/remote/user';
+import Button from '@components/button';
+import SearchBar from '@components/search';
+import ServerUserList from '@components/server_user_list';
+import {General, Screens} from '@constants';
+import DatabaseManager from '@database/manager';
+import {popTopScreen} from '@screens/navigation';
+import {act, renderWithEverything} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import SelectUser from './select_user';
+
+import type {Database} from '@nozbe/watermelondb';
+
+const serverUrl = 'some.server.url';
+jest.mock('@context/server', () => ({
+ useServerUrl: jest.fn().mockReturnValue(serverUrl),
+}));
+jest.mock('@screens/navigation');
+jest.mock('@actions/remote/user');
+
+jest.mock('@components/button', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(Button).mockImplementation(
+ (props) => React.createElement('Button', {testID: 'button', ...props}),
+);
+
+jest.mock('@components/search', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(SearchBar).mockImplementation(
+ (props) => React.createElement('SearchBar', {...props}),
+);
+
+jest.mock('@components/server_user_list', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(ServerUserList).mockImplementation(
+ (props) => React.createElement('ServerUserList', {...props}),
+);
+
+describe('SelectUser', () => {
+ let database: Database;
+
+ beforeEach(async () => {
+ await DatabaseManager.init([serverUrl]);
+ database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
+ });
+
+ afterEach(async () => {
+ await DatabaseManager.destroyServerDatabase(serverUrl);
+ });
+
+ function getBaseProps(): ComponentProps {
+ return {
+ currentTeamId: 'team-1',
+ currentUserId: 'current-user',
+ handleSelect: jest.fn(),
+ componentId: 'PlaybookSelectUser',
+ participantIds: ['participant-1', 'participant-2'],
+ };
+ }
+
+ it('renders correctly with basic props', () => {
+ const props = getBaseProps();
+ const {getByTestId, queryByTestId} = renderWithEverything(, {database});
+
+ const searchBar = getByTestId('selector.search_bar');
+ expect(searchBar).toHaveProp('placeholder', 'Search');
+ expect(searchBar).toHaveProp('onChangeText', expect.any(Function));
+ expect(searchBar).toHaveProp('value', '');
+ expect(searchBar).toHaveProp('autoCapitalize', 'none');
+
+ expect(queryByTestId('button')).toBeNull();
+
+ const userList = getByTestId('integration_selector.user_list');
+ expect(userList).toHaveProp('currentUserId', props.currentUserId);
+ expect(userList).toHaveProp('term', '');
+ expect(userList).toHaveProp('tutorialWatched', true);
+ expect(userList).toHaveProp('handleSelectProfile', expect.any(Function));
+ expect(userList).toHaveProp('selectedIds', new Set(props.participantIds));
+ expect(userList).toHaveProp('fetchFunction', expect.any(Function));
+ expect(userList).toHaveProp('searchFunction', expect.any(Function));
+ expect(userList).toHaveProp('createFilter', expect.any(Function));
+ expect(userList).toHaveProp('location', Screens.PLAYBOOK_SELECT_USER);
+ expect(userList).toHaveProp('customSection', expect.any(Function));
+ });
+
+ it('renders no assignee button when handleRemove is provided and no search term', () => {
+ const props = getBaseProps();
+ props.handleRemove = jest.fn();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const button = getByTestId('button');
+ expect(button).toHaveProp('text', 'No Assignee');
+ expect(button).toHaveProp('onPress', expect.any(Function));
+ expect(button).toHaveProp('emphasis', 'link');
+ });
+
+ it('does not render no assignee button when search term is present', () => {
+ const props = getBaseProps();
+ props.handleRemove = jest.fn();
+ const {queryByTestId, getByTestId} = renderWithEverything(, {database});
+
+ expect(getByTestId('button')).toBeTruthy();
+
+ const searchBar = getByTestId('selector.search_bar');
+ act(() => {
+ searchBar.props.onChangeText('test');
+ });
+
+ expect(queryByTestId('button')).toBeNull();
+ });
+
+ it('handles search term changes correctly', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const searchBar = getByTestId('selector.search_bar');
+ act(() => {
+ searchBar.props.onChangeText('test');
+ });
+
+ expect(searchBar).toHaveProp('value', 'test');
+
+ const serverUserList = getByTestId('integration_selector.user_list');
+ expect(serverUserList).toHaveProp('term', 'test');
+ });
+
+ it('clears search term when empty string is passed', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const searchBar = getByTestId('selector.search_bar');
+ act(() => {
+ searchBar.props.onChangeText('test');
+ });
+
+ expect(searchBar).toHaveProp('value', 'test');
+
+ act(() => {
+ searchBar.props.onChangeText('');
+ });
+
+ expect(searchBar).toHaveProp('value', '');
+ const serverUserList = getByTestId('integration_selector.user_list');
+ expect(serverUserList).toHaveProp('term', '');
+ });
+
+ it('handles profile selection correctly', () => {
+ const props = getBaseProps();
+ const mockUser = TestHelper.fakeUser({id: 'user-1'});
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const serverUserList = getByTestId('integration_selector.user_list');
+ serverUserList.props.handleSelectProfile(mockUser);
+
+ expect(props.handleSelect).toHaveBeenCalledWith(mockUser);
+ expect(popTopScreen).toHaveBeenCalled();
+ });
+
+ it('handles remove action correctly', () => {
+ const props = getBaseProps();
+ props.handleRemove = jest.fn();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const button = getByTestId('button');
+ button.props.onPress();
+
+ expect(props.handleRemove).toHaveBeenCalled();
+ expect(props.handleSelect).not.toHaveBeenCalled();
+ expect(popTopScreen).toHaveBeenCalled();
+ });
+
+ it('creates empty selectedIds set when selected prop is not provided', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const serverUserList = getByTestId('integration_selector.user_list');
+ expect(serverUserList).toHaveProp('selectedIds', new Set());
+ });
+
+ it('provides correct fetch function for profiles in team', async () => {
+ const props = getBaseProps();
+ const mockUsers = [TestHelper.fakeUser({id: 'user-1'})];
+ jest.mocked(fetchProfilesInTeam).mockResolvedValue({users: mockUsers});
+
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const serverUserList = getByTestId('integration_selector.user_list');
+ const result = await serverUserList.props.fetchFunction(1);
+
+ expect(fetchProfilesInTeam).toHaveBeenCalledWith(
+ serverUrl,
+ props.currentTeamId,
+ 1,
+ General.PROFILE_CHUNK_SIZE,
+ undefined,
+ {active: true},
+ );
+ expect(result).toEqual(mockUsers);
+ });
+
+ it('provides correct search function for profiles', async () => {
+ const props = getBaseProps();
+ const mockUsers = [TestHelper.fakeUser({id: 'user-1'})];
+ jest.mocked(searchProfiles).mockResolvedValue({data: mockUsers});
+
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const serverUserList = getByTestId('integration_selector.user_list');
+ const result = await serverUserList.props.searchFunction('test');
+
+ expect(searchProfiles).toHaveBeenCalledWith(
+ serverUrl,
+ 'test',
+ {team_id: props.currentTeamId, allow_inactive: false},
+ );
+ expect(result).toEqual(mockUsers);
+ });
+
+ it('returns empty array when fetchProfilesInTeam returns no users', async () => {
+ const props = getBaseProps();
+ jest.mocked(fetchProfilesInTeam).mockResolvedValue({users: []});
+
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const serverUserList = getByTestId('integration_selector.user_list');
+ const result = await serverUserList.props.fetchFunction(1);
+
+ expect(result).toEqual([]);
+ });
+
+ it('returns empty array when searchProfiles returns no data', async () => {
+ const props = getBaseProps();
+ jest.mocked(searchProfiles).mockResolvedValue({data: []});
+
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const serverUserList = getByTestId('integration_selector.user_list');
+ const result = await serverUserList.props.searchFunction('test');
+
+ expect(result).toEqual([]);
+ });
+
+ it('creates user filter that prioritizes exact username matches', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const serverUserList = getByTestId('integration_selector.user_list');
+ const createFilter = serverUserList.props.createFilter;
+ const exactMatches: UserProfile[] = [];
+ const filter = createFilter(exactMatches, 'test');
+
+ const user1 = TestHelper.fakeUser({username: 'test'});
+ const user2 = TestHelper.fakeUser({username: 'testuser'});
+ const user3 = TestHelper.fakeUser({username: 'other'});
+
+ expect(filter(user1)).toBe(false);
+ expect(filter(user2)).toBe(false);
+ expect(filter(user3)).toBe(true);
+
+ expect(exactMatches).toContain(user1);
+ expect(exactMatches).toContain(user2);
+ expect(exactMatches).not.toContain(user3);
+ });
+
+ it('creates custom sections for participants and non-participants', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const serverUserList = getByTestId('integration_selector.user_list');
+ const customSection = serverUserList.props.customSection;
+
+ const participantUser = TestHelper.fakeUser({id: 'participant-1'});
+ const nonParticipantUser = TestHelper.fakeUser({id: 'non-participant'});
+ const profiles = [nonParticipantUser, participantUser];
+
+ const sections = customSection(profiles);
+
+ expect(sections).toHaveLength(2);
+ expect(sections[0].id).toBe('RUN PARTICIPANTS');
+ expect(sections[0].data).toContain(participantUser);
+ expect(sections[1].id).toBe('NOT PARTICIPATING');
+ expect(sections[1].data).toContain(nonParticipantUser);
+ });
+
+ it('returns empty array when no profiles provided to custom section', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const serverUserList = getByTestId('integration_selector.user_list');
+ const customSection = serverUserList.props.customSection;
+
+ const sections = customSection([]);
+
+ expect(sections).toEqual([]);
+ });
+});
diff --git a/app/products/playbooks/screens/select_user/select_user.tsx b/app/products/playbooks/screens/select_user/select_user.tsx
new file mode 100644
index 000000000..24a072852
--- /dev/null
+++ b/app/products/playbooks/screens/select_user/select_user.tsx
@@ -0,0 +1,270 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import {defineMessages, useIntl} from 'react-intl';
+import {View} from 'react-native';
+import {SafeAreaView} from 'react-native-safe-area-context';
+
+import {fetchProfilesInTeam, searchProfiles} from '@actions/remote/user';
+import Button from '@components/button';
+import SearchBar from '@components/search';
+import ServerUserList from '@components/server_user_list';
+import {General, Screens} from '@constants';
+import {useServerUrl} from '@context/server';
+import {useTheme} from '@context/theme';
+import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
+import SecurityManager from '@managers/security_manager';
+import {
+ popTopScreen,
+} from '@screens/navigation';
+import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
+import {typography} from '@utils/typography';
+
+import type {AvailableScreens} from '@typings/screens/navigation';
+
+const close = () => {
+ popTopScreen();
+};
+
+export type Props = {
+ currentTeamId: string;
+ currentUserId: string;
+ handleSelect: (opt: UserProfile) => void;
+ handleRemove?: () => void;
+ selected?: string;
+ componentId: AvailableScreens;
+ participantIds: string[];
+}
+
+const messages = defineMessages({
+ participants: {
+ id: 'playbooks.select_user.participants',
+ defaultMessage: 'RUN PARTICIPANTS',
+ },
+ notParticipants: {
+ id: 'playbooks.select_user.not_participants',
+ defaultMessage: 'NOT PARTICIPATING',
+ },
+});
+
+const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
+ return {
+ container: {
+ flex: 1,
+ },
+ searchBar: {
+ marginVertical: 12,
+ height: 38,
+ paddingHorizontal: 12,
+ flexDirection: 'row',
+ },
+ searchbarComponentContainer: {
+ flex: 1,
+ },
+ loadingContainer: {
+ alignItems: 'center',
+ backgroundColor: theme.centerChannelBg,
+ height: 70,
+ justifyContent: 'center',
+ },
+ loadingText: {
+ color: changeOpacity(theme.centerChannelColor, 0.6),
+ },
+ noResultContainer: {
+ flexGrow: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ noResultText: {
+ color: changeOpacity(theme.centerChannelColor, 0.5),
+ ...typography('Body', 600, 'Regular'),
+ },
+ searchBarInput: {
+ color: theme.centerChannelColor,
+ ...typography('Body', 200, 'Regular'),
+ },
+ };
+});
+
+function SelectUser({
+ selected,
+ handleSelect,
+ handleRemove,
+ currentTeamId,
+ currentUserId,
+ componentId,
+ participantIds,
+}: Props) {
+ const serverUrl = useServerUrl();
+ const theme = useTheme();
+ const searchTimeoutId = useRef(null);
+ const style = getStyleSheet(theme);
+ const intl = useIntl();
+
+ // HOOKS
+ const [term, setTerm] = useState('');
+
+ const selectedIds = useMemo(() => {
+ if (selected) {
+ return new Set([selected]);
+ }
+ return new Set();
+ }, [selected]);
+
+ // Callbacks
+ const clearSearch = useCallback(() => {
+ setTerm('');
+ }, []);
+
+ const handleSelectProfile = useCallback((user: UserProfile): void => {
+ handleSelect(user);
+ close();
+ }, [handleSelect]);
+
+ const handleSelectRemove = useCallback(() => {
+ handleRemove?.();
+ close();
+ }, [handleRemove]);
+
+ // Effects
+ useAndroidHardwareBackHandler(componentId, close);
+
+ useEffect(() => {
+ return () => {
+ if (searchTimeoutId.current) {
+ clearTimeout(searchTimeoutId.current);
+ searchTimeoutId.current = null;
+ }
+ };
+ }, []);
+
+ const onSearch = useCallback((text: string) => {
+ if (!text) {
+ clearSearch();
+ return;
+ }
+
+ setTerm(text);
+
+ if (searchTimeoutId.current) {
+ clearTimeout(searchTimeoutId.current);
+ }
+ }, [clearSearch]);
+
+ const userFetchFunction = useCallback(async (userFetchPage: number) => {
+ const result = await fetchProfilesInTeam(serverUrl, currentTeamId, userFetchPage, General.PROFILE_CHUNK_SIZE, undefined, {active: true});
+ if (result.users?.length) {
+ return result.users;
+ }
+
+ return [];
+ }, [serverUrl, currentTeamId]);
+
+ const userSearchFunction = useCallback(async (searchTerm: string) => {
+ const lowerCasedTerm = searchTerm.toLowerCase();
+ const results = await searchProfiles(serverUrl, lowerCasedTerm, {team_id: currentTeamId, allow_inactive: false});
+
+ if (results.data) {
+ return results.data;
+ }
+
+ return [];
+ }, [currentTeamId, serverUrl]);
+
+ const createUserFilter = useCallback((exactMatches: UserProfile[], searchTerm: string) => {
+ return (p: UserProfile) => {
+ if (p.username === searchTerm || p.username.startsWith(searchTerm)) {
+ exactMatches.push(p);
+ return false;
+ }
+
+ return true;
+ };
+ }, []);
+
+ const customSection = useCallback((profiles: UserProfile[]) => {
+ if (!profiles.length) {
+ return [];
+ }
+
+ const sections = new Map();
+ const participantsKey = intl.formatMessage(messages.participants);
+ const notParticipantsKey = intl.formatMessage(messages.notParticipants);
+
+ const participantsMap = new Set();
+ participantIds.forEach((id) => {
+ participantsMap.add(id);
+ });
+
+ profiles.forEach((p) => {
+ const sectionKey = participantsMap.has(p.id) ? participantsKey : notParticipantsKey;
+ const sectionValue = sections.get(sectionKey) || [];
+ const section = [...sectionValue, p];
+ sections.set(sectionKey, section);
+ });
+
+ const results = [];
+ let index = 0;
+ for (const k of [participantsKey, notParticipantsKey]) { // This is to ensure order of sections
+ const v = sections.get(k) || [];
+ if (v.length) {
+ results.push({
+ first: index === 0,
+ id: k,
+ data: v,
+ });
+ index++;
+ }
+ }
+ return results;
+ }, [intl, participantIds]);
+
+ return (
+
+
+
+ {Boolean(handleRemove) && !term && (
+
+ )}
+
+
+
+ );
+}
+
+export default SelectUser;
diff --git a/app/products/playbooks/types/database/models/playbook_run.ts b/app/products/playbooks/types/database/models/playbook_run.ts
index 52c6c6841..447041960 100644
--- a/app/products/playbooks/types/database/models/playbook_run.ts
+++ b/app/products/playbooks/types/database/models/playbook_run.ts
@@ -107,9 +107,6 @@ declare class PlaybookRunModel extends Model {
/** checklists : The CHECKLISTS associated with this Playbook Run */
checklists: Query;
- /** participants : The USERS that participate in this Playbook Run */
- participants: () => Query;
-
/** prepareDestroyWithRelations : Prepare the model for deletion with its relations */
prepareDestroyWithRelations: () => Promise;
}
diff --git a/app/products/playbooks/utils/run.test.ts b/app/products/playbooks/utils/run.test.ts
index a1798bf5c..5c33c4195 100644
--- a/app/products/playbooks/utils/run.test.ts
+++ b/app/products/playbooks/utils/run.test.ts
@@ -106,6 +106,10 @@ describe('run utils', () => {
expect(isRunFinished(run)).toBe(true);
});
+
+ it('should return true for undefined run', () => {
+ expect(isRunFinished(undefined)).toBe(true);
+ });
});
describe('getMaxRunUpdateAt', () => {
diff --git a/app/products/playbooks/utils/run.ts b/app/products/playbooks/utils/run.ts
index 094a56097..6365f6196 100644
--- a/app/products/playbooks/utils/run.ts
+++ b/app/products/playbooks/utils/run.ts
@@ -23,7 +23,10 @@ export function getRunScheduledTimestamp(run: PlaybookRunModel | PlaybookRun): n
return timestamp;
}
-export function isRunFinished(run: PlaybookRunModel | PlaybookRun): boolean {
+export function isRunFinished(run?: PlaybookRunModel | PlaybookRun): boolean {
+ if (!run) {
+ return true;
+ }
const currentStatus = 'currentStatus' in run ? run.currentStatus : run.current_status;
return currentStatus === 'Finished';
}
diff --git a/app/screens/channel_add_members/channel_add_members.tsx b/app/screens/channel_add_members/channel_add_members.tsx
index 5ca42356a..75a5b45f2 100644
--- a/app/screens/channel_add_members/channel_add_members.tsx
+++ b/app/screens/channel_add_members/channel_add_members.tsx
@@ -110,10 +110,9 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
};
});
-function removeProfileFromList(list: {[id: string]: UserProfile}, id: string) {
- const newSelectedIds = Object.assign({}, list);
-
- Reflect.deleteProperty(newSelectedIds, id);
+function removeProfileFromList(list: Set, id: string) {
+ const newSelectedIds = new Set(list);
+ newSelectedIds.delete(id);
return newSelectedIds;
}
@@ -137,7 +136,7 @@ export default function ChannelAddMembers({
const [term, setTerm] = useState('');
const [addingMembers, setAddingMembers] = useState(false);
- const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
+ const [selectedIds, setSelectedIds] = useState>(new Set());
const [showBanner, setShowBanner] = useState(Boolean(channel?.abacPolicyEnforced));
// Use the hook to fetch access control attributes
@@ -184,12 +183,12 @@ export default function ChannelAddMembers({
const handleSelectProfile = useCallback((user: UserProfile) => {
clearSearch();
setSelectedIds((current) => {
- if (current[user.id]) {
+ if (current.has(user.id)) {
return removeProfileFromList(current, user.id);
}
- const newSelectedIds = Object.assign({}, current);
- newSelectedIds[user.id] = user;
+ const newSelectedIds = new Set(current);
+ newSelectedIds.add(user.id);
return newSelectedIds;
});
diff --git a/app/screens/component_library/chip.cl.tsx b/app/screens/component_library/chip.cl.tsx
index 75b6c7b90..e67f823e0 100644
--- a/app/screens/component_library/chip.cl.tsx
+++ b/app/screens/component_library/chip.cl.tsx
@@ -8,7 +8,7 @@ import BaseChip from '@components/chips/base_chip';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
-import {useBooleanProp, useStringProp} from './hooks';
+import {useBooleanProp, useDropdownProp, useStringProp} from './hooks';
import {buildComponent} from './utils';
const propPossibilities = {};
@@ -18,13 +18,15 @@ const onPress = () => Alert.alert('Button pressed!');
const ChipComponentLibrary = () => {
const theme = useTheme();
const [label, labelSelector] = useStringProp('label', 'Chip text', false);
- const [showRemoveOption, showRemoveOptionSelector] = useBooleanProp('showRemoveOption', false);
+ const [actionIcon, actionIconPossibilities, actionIconSelector] = useDropdownProp('actionIcon', 'remove', ['remove', 'downArrow'], true);
const [hasPrefix, hasPrefixSelector] = useBooleanProp('hasPrefix', false);
const components = useMemo(
- () => buildComponent(BaseChip, propPossibilities, [], [
+ () => buildComponent(BaseChip, propPossibilities, [
+ actionIconPossibilities,
+ ], [
label,
- showRemoveOption,
+ actionIcon,
{
onPress,
prefix: hasPrefix.hasPrefix ? (
@@ -39,13 +41,13 @@ const ChipComponentLibrary = () => {
theme,
},
]),
- [label, showRemoveOption, hasPrefix.hasPrefix, theme],
+ [label, actionIconPossibilities, actionIcon, hasPrefix.hasPrefix, theme],
);
return (
<>
{labelSelector}
- {showRemoveOptionSelector}
+ {actionIconSelector}
{hasPrefixSelector}
{components}
>
diff --git a/app/screens/create_direct_message/create_direct_message.tsx b/app/screens/create_direct_message/create_direct_message.tsx
index 7ec4ab4c4..e71075f13 100644
--- a/app/screens/create_direct_message/create_direct_message.tsx
+++ b/app/screens/create_direct_message/create_direct_message.tsx
@@ -24,7 +24,6 @@ import {dismissModal, setButtons} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
-import {displayUsername} from '@utils/user';
import type {AvailableScreens} from '@typings/screens/navigation';
@@ -95,10 +94,9 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
};
});
-function removeProfileFromList(list: {[id: string]: UserProfile}, id: string) {
- const newSelectedIds = Object.assign({}, list);
-
- Reflect.deleteProperty(newSelectedIds, id);
+function removeProfileFromList(list: Set, id: string) {
+ const newSelectedIds = new Set(list);
+ newSelectedIds.delete(id);
return newSelectedIds;
}
@@ -122,9 +120,9 @@ export default function CreateDirectMessage({
const [term, setTerm] = useState('');
const [startingConversation, setStartingConversation] = useState(false);
- const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
+ const [selectedIds, setSelectedIds] = useState>(new Set());
const [showToast, setShowToast] = useState(false);
- const selectedCount = Object.keys(selectedIds).length;
+ const selectedCount = selectedIds.size;
const clearSearch = useCallback(() => {
setTerm('');
@@ -134,17 +132,15 @@ export default function CreateDirectMessage({
setSelectedIds((current) => removeProfileFromList(current, id));
}, []);
- const createDirectChannel = useCallback(async (id: string, selectedUser?: UserProfile): Promise => {
- const user = selectedUser || selectedIds[id];
- const displayName = displayUsername(user, intl.locale, teammateNameDisplay);
- const result = await makeDirectChannel(serverUrl, id, displayName);
+ const createDirectChannel = useCallback(async (id: string): Promise => {
+ const result = await makeDirectChannel(serverUrl, id);
if (result.error) {
alertErrorWithFallback(intl, result.error, messages.dm);
}
return !result.error;
- }, [selectedIds, intl, teammateNameDisplay, serverUrl]);
+ }, [intl, serverUrl]);
const createGroupChannel = useCallback(async (ids: string[]): Promise => {
const result = await makeGroupChannel(serverUrl, ids);
@@ -156,7 +152,7 @@ export default function CreateDirectMessage({
return !result.error;
}, [intl, serverUrl]);
- const startConversation = useCallback(async (selectedId?: {[id: string]: boolean}, selectedUser?: UserProfile) => {
+ const startConversation = useCallback(async (selectedId?: {[id: string]: boolean}) => {
if (startingConversation) {
return;
}
@@ -170,7 +166,7 @@ export default function CreateDirectMessage({
} else if (idsToUse.length > 1) {
success = await createGroupChannel(idsToUse);
} else {
- success = await createDirectChannel(idsToUse[0], selectedUser);
+ success = await createDirectChannel(idsToUse[0]);
}
if (success) {
@@ -186,25 +182,21 @@ export default function CreateDirectMessage({
[currentUserId]: true,
};
- startConversation(selectedId, user);
+ startConversation(selectedId);
} else {
clearSearch();
setSelectedIds((current) => {
- if (current[user.id]) {
+ if (current.has(user.id)) {
return removeProfileFromList(current, user.id);
}
- const wasSelected = current[user.id];
-
- if (!wasSelected && selectedCount >= General.MAX_USERS_IN_GM) {
+ if (selectedCount >= General.MAX_USERS_IN_GM) {
setShowToast(true);
return current;
}
- const newSelectedIds = Object.assign({}, current);
- if (!wasSelected) {
- newSelectedIds[user.id] = user;
- }
+ const newSelectedIds = new Set(current);
+ newSelectedIds.add(user.id);
return newSelectedIds;
});
diff --git a/app/screens/integration_selector/integration_selector.tsx b/app/screens/integration_selector/integration_selector.tsx
index 72298204e..6d589ae16 100644
--- a/app/screens/integration_selector/integration_selector.tsx
+++ b/app/screens/integration_selector/integration_selector.tsx
@@ -560,6 +560,14 @@ function IntegrationSelector(
};
}, []);
+ const selectedUserIds = useMemo>(() => {
+ if (dataSource === ViewConstants.DATA_SOURCE_USERS) {
+ return new Set(Object.keys(selectedIds));
+ }
+
+ return new Set();
+ }, [dataSource, selectedIds]);
+
const renderDataTypeList = () => {
switch (dataSource) {
case ViewConstants.DATA_SOURCE_USERS:
@@ -569,7 +577,7 @@ function IntegrationSelector(
term={term}
tutorialWatched={true}
handleSelectProfile={handleSelectProfile}
- selectedIds={selectedIds as {[id: string]: UserProfile}}
+ selectedIds={selectedUserIds}
fetchFunction={userFetchFunction}
searchFunction={userSearchFunction}
createFilter={createUserFilter}
diff --git a/app/screens/manage_channel_members/manage_channel_members.tsx b/app/screens/manage_channel_members/manage_channel_members.tsx
index 901704d70..89c561062 100644
--- a/app/screens/manage_channel_members/manage_channel_members.tsx
+++ b/app/screens/manage_channel_members/manage_channel_members.tsx
@@ -73,7 +73,7 @@ const sortUsers = (a: UserProfile, b: UserProfile, locale: string, teammateDispl
const MANAGE_BUTTON = 'manage-button';
const EMPTY: UserProfile[] = [];
const EMPTY_MEMBERS: ChannelMembership[] = [];
-const EMPTY_IDS = {};
+const EMPTY_IDS = new Set();
const {USER_PROFILE} = Screens;
const CLOSE_BUTTON_ID = 'close-user-profile';
const TEST_ID = 'manage_members';
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index 5fa1fb4f2..1cbb59a98 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -1035,6 +1035,9 @@
"playbooks.runs.in_progress.description": "When a run starts in this channel, you’ll see it here.",
"playbooks.runs.in_progress.title": "No in progress runs",
"playbooks.runs.show_more": "Show More",
+ "playbooks.select_user.no_assignee": "No Assignee",
+ "playbooks.select_user.not_participants": "NOT PARTICIPATING",
+ "playbooks.select_user.participants": "RUN PARTICIPANTS",
"playbooks.status_update_post.num_tasks": "**{numTasksChecked, number}** of **{numTasks, number}** {numTasks, plural, =1 {task} other {tasks}} checked",
"playbooks.status_update_post.participants": "{numParticipants, number} {numParticipants, plural, =1 {participant} other {participants}}",
"playbooks.status_update_post.update": "@{authorUsername} posted an update for [{runName}]({link})",
diff --git a/test/test_helper.ts b/test/test_helper.ts
index e4f300798..30ac4cc4f 100644
--- a/test/test_helper.ts
+++ b/test/test_helper.ts
@@ -1211,7 +1211,6 @@ class TestHelperSingleton {
channel: this.fakeRelation(),
owner: this.fakeRelation(),
checklists: this.fakeQuery([]),
- participants: () => this.fakeQuery([]),
prepareDestroyWithRelations: jest.fn().mockResolvedValue([]),
previousReminder: 0,
itemsOrder: [],