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