Add select user screen to select owner and task assignee (#9089)

* Add select user screen to select owner and task assignee

* Fix i18n

* Add tests

* Address feedback

* Fix test

* Address UX feedback

* Fix test

* Put the no assignee button inline with the search
This commit is contained in:
Daniel Espino García 2025-09-12 12:13:25 +02:00 committed by GitHub
parent 530cbb4ba5
commit a5d7bf7db4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 2138 additions and 141 deletions

View file

@ -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(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
showRemoveOption={true}
actionIcon='remove'
/>,
);
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(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
actionIcon='downArrow'
/>,
);
expect(getByTestId('base_chip.downArrow.button')).toBeTruthy();
});
it('should not render the X button when actionIcon is undefined', () => {
const {queryByTestId} = renderWithIntlAndTheme(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
showRemoveOption={false}
actionIcon={undefined}
/>,
);
@ -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}
/>,
);

View file

@ -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}
<TouchableOpacity
style={style.remove}
onPress={onPress}
testID={`${testID}.remove.button`}
testID={`${testID}.${actionIcon}.button`}
>
<CompassIcon
name='close-circle'
name={iconName}
size={16}
color={changeOpacity(theme.centerChannelColor, 0.32)}
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
</TouchableOpacity>
</>

View file

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

View file

@ -26,7 +26,7 @@ export default function SelectedChip({
<BaseChip
testID={testID}
onPress={onPress}
showRemoveOption={true}
actionIcon='remove'
showAnimation={true}
label={text}
/>

View file

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

View file

@ -24,7 +24,7 @@ export default function SelectedUserChip({
<UserChip
testID={testID}
onPress={onPress}
showRemoveOption={true}
actionIcon='remove'
showAnimation={true}
teammateNameDisplay={teammateNameDisplay}
user={user}

View file

@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import {General} from '@constants';
import DatabaseManager from '@database/manager';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import SelectedUserChipByIdComponent from './selected_user_chip_by_id';
import SelectedUserChipById from './index';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
const serverUrl = 'server-url';
jest.mock('./selected_user_chip_by_id', () => ({
__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<typeof SelectedUserChipById> {
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(
<SelectedUserChipById {...baseProps}/>,
{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(
<SelectedUserChipById {...baseProps}/>,
{database},
);
const chip = getByTestId('test-chip');
expect(chip).toHaveProp('user', expect.objectContaining({id: userId}));
});
});
});

View file

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

View file

@ -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<typeof SelectedUserChipById> {
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(
<SelectedUserChipById {...props}/>,
);
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(
<SelectedUserChipById {...props}/>,
);
expect(queryByTestId('test-selected-user-chip')).toBeNull();
});
});

View file

@ -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 (
<SelectedUserChip
testID={testID}
onPress={onPress}
teammateNameDisplay={teammateNameDisplay}
user={user}
/>
);
}

View file

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

View file

@ -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({
<BaseChip
testID={testID}
onPress={onPress}
showRemoveOption={showRemoveOption}
actionIcon={actionIcon}
showAnimation={showAnimation}
label={name}
prefix={picture}

View file

@ -8,7 +8,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context';
import Button from '@components/button';
import {CHIP_HEIGHT} from '@components/chips/constants';
import SelectedUserChip from '@components/chips/selected_user_chip';
import SelectedUserChipById from '@components/chips/selected_user_chip_by_id';
import Toast from '@components/toast';
import {useTheme} from '@context/theme';
import {useIsTablet, useKeyboardHeightWithDuration} from '@hooks/device';
@ -44,7 +44,7 @@ type Props = {
/**
* An object mapping user ids to a falsey value indicating whether or not they have been selected.
*/
selectedIds: {[id: string]: UserProfile};
selectedIds: Set<string>;
/**
* 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(
<SelectedUserChip
key={user.id}
user={user}
<SelectedUserChipById
key={userId}
userId={userId}
onPress={onRemove}
teammateNameDisplay={teammateNameDisplay}
testID={userItemTestID}

View file

@ -10,18 +10,20 @@ import {debounce} from '@helpers/api/general';
import {filterProfilesMatchingTerm} from '@utils/user';
import type {AvailableScreens} from '@typings/screens/navigation';
import type {SectionListData} from 'react-native';
type Props = {
currentUserId: string;
tutorialWatched: boolean;
handleSelectProfile: (user: UserProfile) => void;
term: string;
selectedIds: {[id: string]: UserProfile};
selectedIds: Set<string>;
fetchFunction: (page: number) => Promise<UserProfile[]>;
searchFunction: (term: string) => Promise<UserProfile[]>;
createFilter: (exactMatches: UserProfile[], term: string) => ((p: UserProfile) => boolean);
testID: string;
location: AvailableScreens;
customSection?: (profiles: UserProfile[]) => Array<SectionListData<UserProfile>>;
}
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}
/>
);
}

View file

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

View file

@ -179,12 +179,13 @@ type Props = {
manageMode?: boolean;
showManageMode?: boolean;
showNoResults: boolean;
selectedIds: {[id: string]: UserProfile};
selectedIds: Set<string>;
testID?: string;
term?: string;
tutorialWatched: boolean;
includeUserMargin?: boolean;
location: AvailableScreens;
customSection?: (profiles: UserProfile[]) => Array<SectionListData<UserProfile>>;
}
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;

View file

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

View file

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

View file

@ -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<PlaybookRunModel>(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<PlaybookRunModel>(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<PlaybookRunModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId);
expect(updatedRun.ownerUserId).toBe(emptyOwnerId);
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ export interface ClientPlaybooksMix {
// Playbook Runs
fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise<FetchPlaybookRunsReturn>;
fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise<PlaybookRun>;
setOwner: (playbookRunId: string, ownerId: string) => Promise<void>;
// Run Management
// finishRun: (playbookRunId: string) => Promise<any>;
@ -18,6 +19,7 @@ export interface ClientPlaybooksMix {
setChecklistItemState: (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => Promise<void>;
skipChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise<void>;
restoreChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise<void>;
setAssignee: (playbookRunId: string, checklistNum: number, itemNum: number, assigneeId?: string) => Promise<void>;
// skipChecklist: (playbookRunID: string, checklistNum: number) => Promise<void>;
@ -62,6 +64,14 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(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 = <TBase extends Constructor<ClientBase>>(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 = <TBase extends Constructor<ClientBase>>(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(

View file

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

View file

@ -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<PlaybookChecklistModel>;
participants = (): Query<UserModel> => {
const filteredParticipantIds = this.participantIds.filter((id) => id !== this.ownerUserId);
return this.database.get<UserModel>(USER).query(Q.where('id', Q.oneOf(filteredParticipantIds)));
};
prepareDestroyWithRelations = async (): Promise<Model[]> => {
const preparedModels: Model[] = [this.prepareDestroyPermanently()];

View file

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

View file

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

View file

@ -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) => <Text {...props}>{Screens.PLAYBOOK_EDIT_COMMAND}</Text>);
jest.mock('@playbooks/screens/select_user', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(SelectUser).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOK_SELECT_USER}</Text>);
describe('Screen Registration', () => {
beforeEach(() => {
jest.clearAllMocks();

View file

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

View file

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

View file

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

View file

@ -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(<ChecklistItemBottomSheet {...props}/>);
let assigneeItem = getByTestId('checklist_item.assignee');
expect(assigneeItem).toHaveProp('type', 'none');
expect(assigneeItem).toHaveProp('action', undefined);
props.isDisabled = false;
rerender(<ChecklistItemBottomSheet {...props}/>);
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(<ChecklistItemBottomSheet {...props}/>);
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(<ChecklistItemBottomSheet {...props}/>);
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(<ChecklistItemBottomSheet {...props}/>);
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', () => {

View file

@ -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 = () => (
<View style={styles.taskDetailsContainer}>
<OptionItem
type='none'
icon='account-multiple-plus-outline'
type={isDisabled ? 'none' : 'arrow'}
icon='account-plus-outline'
label={intl.formatMessage(messages.assignee)}
info={assigneeInfo}
testID='checklist_item.assignee'
action={isDisabled ? undefined : openUserSelector}
/>
<OptionItem
type='none'

View file

@ -52,6 +52,13 @@ describe('ChecklistItemBottomSheet Enhanced Component', () => {
});
}
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(<ChecklistItemBottomSheet {...props}/>, {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(<ChecklistItemBottomSheet {...props}/>, {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 () => {

View file

@ -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$([]),
};
});

View file

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

View file

@ -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(<PlaybookRun {...props}/>, {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(<PlaybookRun {...props}/>, {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(<PlaybookRun {...props}/>, {database});

View file

@ -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 <ErrorState/>;
}
const isFinished = isRunFinished(playbookRun);
return (
<>
<OutOfDateHeader
@ -224,8 +253,9 @@ export default function PlaybookRun({
<View style={styles.ownerRow}>
<UserChip
user={owner}
onPress={openOwnerProfile}
onPress={readOnly ? openOwnerProfile : openChangeOwnerModal}
teammateNameDisplay={teammateNameDisplay}
actionIcon={readOnly ? undefined : 'downArrow'}
/>
</View>
</View>

View file

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

View file

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

View file

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

View file

@ -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<typeof SelectUser> {
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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {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(<SelectUser {...props}/>, {database});
const serverUserList = getByTestId('integration_selector.user_list');
const customSection = serverUserList.props.customSection;
const sections = customSection([]);
expect(sections).toEqual([]);
});
});

View file

@ -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<NodeJS.Timeout | null>(null);
const style = getStyleSheet(theme);
const intl = useIntl();
// HOOKS
const [term, setTerm] = useState<string>('');
const selectedIds = useMemo(() => {
if (selected) {
return new Set([selected]);
}
return new Set<string>();
}, [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<string, UserProfile[]>();
const participantsKey = intl.formatMessage(messages.participants);
const notParticipantsKey = intl.formatMessage(messages.notParticipants);
const participantsMap = new Set<string>();
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 (
<SafeAreaView
nativeID={SecurityManager.getShieldScreenId(componentId)}
style={style.container}
>
<View
testID='integration_selector.screen'
style={style.searchBar}
>
<SearchBar
testID='selector.search_bar'
placeholder={intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
inputStyle={style.searchBarInput}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
onChangeText={onSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
containerStyle={style.searchbarComponentContainer}
/>
{Boolean(handleRemove) && !term && (
<Button
text={intl.formatMessage({id: 'playbooks.select_user.no_assignee', defaultMessage: 'No Assignee'})}
theme={theme}
onPress={handleSelectRemove}
emphasis='link'
/>
)}
</View>
<ServerUserList
currentUserId={currentUserId}
term={term}
tutorialWatched={true}
handleSelectProfile={handleSelectProfile}
selectedIds={selectedIds}
fetchFunction={userFetchFunction}
searchFunction={userSearchFunction}
createFilter={createUserFilter}
testID={'integration_selector.user_list'}
location={Screens.PLAYBOOK_SELECT_USER}
customSection={customSection}
/>
</SafeAreaView>
);
}
export default SelectUser;

View file

@ -107,9 +107,6 @@ declare class PlaybookRunModel extends Model {
/** checklists : The CHECKLISTS associated with this Playbook Run */
checklists: Query<PlaybookChecklistModel>;
/** participants : The USERS that participate in this Playbook Run */
participants: () => Query<UserModel>;
/** prepareDestroyWithRelations : Prepare the model for deletion with its relations */
prepareDestroyWithRelations: () => Promise<Model[]>;
}

View file

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

View file

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

View file

@ -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<string>, 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<Set<string>>(new Set<string>());
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;
});

View file

@ -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}
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 16}}>{components}</View>
</>

View file

@ -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<string>, 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<Set<string>>(new Set<string>());
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<boolean> => {
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<boolean> => {
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<boolean> => {
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;
});

View file

@ -560,6 +560,14 @@ function IntegrationSelector(
};
}, []);
const selectedUserIds = useMemo<Set<string>>(() => {
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}

View file

@ -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<string>();
const {USER_PROFILE} = Screens;
const CLOSE_BUTTON_ID = 'close-user-profile';
const TEST_ID = 'manage_members';

View file

@ -1035,6 +1035,9 @@
"playbooks.runs.in_progress.description": "When a run starts in this channel, youll 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})",

View file

@ -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: [],