diff --git a/app/components/chips/base_chip.test.tsx b/app/components/chips/base_chip.test.tsx index a53e196ea..6b95394e2 100644 --- a/app/components/chips/base_chip.test.tsx +++ b/app/components/chips/base_chip.test.tsx @@ -11,6 +11,7 @@ import BaseChip from './base_chip'; describe('BaseChip', () => { const onPressMock = jest.fn(); + const onActionPressMock = jest.fn(); afterEach(() => { jest.clearAllMocks(); @@ -28,66 +29,66 @@ describe('BaseChip', () => { expect(getByText('Test Label')).toBeTruthy(); }); - it('should render with the X button when actionIcon is remove', () => { + it('should render with the X button when action is remove', () => { const {getByTestId} = renderWithIntlAndTheme( , ); - expect(getByTestId('base_chip.remove.button')).toBeTruthy(); + expect(getByTestId('base_chip.remove.icon')).toBeTruthy(); }); - it('should render with the chevron down button when actionIcon is downArrow', () => { + it('should render with the chevron down button when action is downArrow', () => { const {getByTestId} = renderWithIntlAndTheme( , ); - expect(getByTestId('base_chip.downArrow.button')).toBeTruthy(); + expect(getByTestId('base_chip.downArrow.icon')).toBeTruthy(); }); - it('should not render the X button when actionIcon is undefined', () => { + it('should not render the action button when action is undefined', () => { const {queryByTestId} = renderWithIntlAndTheme( , ); - expect(queryByTestId('base_chip.remove.button')).toBeNull(); + expect(queryByTestId('base_chip.remove.icon')).toBeNull(); + expect(queryByTestId('base_chip.downArrow.icon')).toBeNull(); }); - it('should call onPress when the X button is pressed', () => { + it('should call onPressMock when the action button is pressed', () => { const {getByTestId} = renderWithIntlAndTheme( , ); fireEvent.press(getByTestId('base_chip.remove.button')); - expect(onPressMock).toHaveBeenCalledTimes(1); + expect(onPressMock).not.toHaveBeenCalled(); + expect(onActionPressMock).toHaveBeenCalledTimes(1); }); - it('should call onPress when the chip is pressed without X button', () => { + it('should call onPress when the chip is pressed without action button', () => { const {getByTestId} = renderWithIntlAndTheme( , ); @@ -95,6 +96,21 @@ describe('BaseChip', () => { expect(onPressMock).toHaveBeenCalledTimes(1); }); + it('should call onPress when the chip is pressed and we have an action button', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + fireEvent.press(getByTestId('base_chip.chip_button')); + expect(onPressMock).toHaveBeenCalledTimes(1); + expect(onActionPressMock).not.toHaveBeenCalled(); + }); + it('should handle animations when showAnimation is true', () => { const {getByTestId} = renderWithIntlAndTheme( void; testID?: string; - actionIcon?: 'remove' | 'downArrow'; + action?: { + icon: 'remove' | 'downArrow'; + onPress?: () => void; + }; showAnimation?: boolean; label: string; prefix?: JSX.Element; @@ -70,7 +73,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { export default function BaseChip({ testID, onPress, - actionIcon, + action, showAnimation, label, prefix, @@ -78,6 +81,9 @@ export default function BaseChip({ type = 'normal', boldText = false, }: SelectedChipProps) { + const actionIcon = action?.icon; + const actionOnPress = action?.onPress; + const theme = useTheme(); const style = getStyleFromTheme(theme); const dimensions = useWindowDimensions(); @@ -118,30 +124,49 @@ export default function BaseChip({ let content = chipContent; if (actionIcon) { const iconName = actionIcon === 'remove' ? 'close-circle' : 'chevron-down'; - content = ( - <> - {chipContent} + let icon = ( + + ); + + if (actionOnPress) { + icon = ( - + {icon} + ); + } else { + icon = ( + + {icon} + + ); + } + + content = ( + <> + {content} + {icon} ); - } else if (onPress) { + } + + if (onPress) { content = ( - {chipContent} + {content} ); } diff --git a/app/components/chips/selected_chip.test.tsx b/app/components/chips/selected_chip.test.tsx index f95fadaf7..d3202819a 100644 --- a/app/components/chips/selected_chip.test.tsx +++ b/app/components/chips/selected_chip.test.tsx @@ -32,10 +32,10 @@ describe('SelectedChip', () => { const baseChip = getByTestId('selected-chip'); expect(baseChip.props.label).toBe('Test Chip'); - expect(baseChip.props.actionIcon).toBe('remove'); + expect(baseChip.props.action).toEqual({icon: 'remove', onPress: expect.any(Function)}); expect(baseChip.props.showAnimation).toBe(true); expect(baseChip.props.prefix).toBeUndefined(); - baseChip.props.onPress(); + baseChip.props.action.onPress(); expect(mockOnRemove).toHaveBeenCalledTimes(1); expect(mockOnRemove).toHaveBeenCalledWith('test-id'); }); diff --git a/app/components/chips/selected_chip.tsx b/app/components/chips/selected_chip.tsx index 2f4ddbbb6..f6a92e7f7 100644 --- a/app/components/chips/selected_chip.tsx +++ b/app/components/chips/selected_chip.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback} from 'react'; +import React, {useMemo} from 'react'; import BaseChip from './base_chip'; @@ -18,15 +18,12 @@ export default function SelectedChip({ onRemove, testID, }: SelectedChipProps) { - const onPress = useCallback(() => { - onRemove(id); - }, [onRemove, id]); + const action = useMemo(() => ({icon: 'remove' as const, onPress: () => onRemove(id)}), [id, onRemove]); return ( diff --git a/app/components/chips/selected_user_chip.test.tsx b/app/components/chips/selected_user_chip.test.tsx index 303156984..36cf0e8d7 100644 --- a/app/components/chips/selected_user_chip.test.tsx +++ b/app/components/chips/selected_user_chip.test.tsx @@ -32,9 +32,9 @@ describe('SelectedUserChip', () => { const userChip = getByTestId('selected-user-chip'); expect(userChip.props.user).toBe(mockUser); expect(userChip.props.teammateNameDisplay).toBe('username'); - expect(userChip.props.actionIcon).toBe('remove'); + expect(userChip.props.action).toEqual({icon: 'remove', onPress: expect.any(Function)}); expect(userChip.props.showAnimation).toBe(true); - userChip.props.onPress(); + userChip.props.action.onPress(); expect(mockOnPress).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/chips/selected_user_chip.tsx b/app/components/chips/selected_user_chip.tsx index a05087d7f..2f5b899a4 100644 --- a/app/components/chips/selected_user_chip.tsx +++ b/app/components/chips/selected_user_chip.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useMemo} from 'react'; import UserChip from './user_chip'; @@ -20,11 +20,11 @@ export default function SelectedUserChip({ teammateNameDisplay, onPress, }: SelectedChipProps) { + const action = useMemo(() => ({icon: 'remove' as const, onPress}), [onPress]); return ( React.createElement('P describe('UserChip', () => { const mockOnPress = jest.fn(); + const mockOnActionPress = jest.fn(); const mockUser = TestHelper.fakeUser({id: 'user-id', username: 'test-user'}); it('should render with the correct props', () => { @@ -34,13 +35,13 @@ describe('UserChip', () => { testID='user-chip' teammateNameDisplay='username' showAnimation={true} - actionIcon='remove' + action={{icon: 'remove', onPress: mockOnActionPress}} />, ); const baseChip = getByTestId('user-chip'); expect(baseChip.props.label).toBe('test-user'); - expect(baseChip.props.actionIcon).toBe('remove'); + expect(baseChip.props.action).toEqual({icon: 'remove', onPress: expect.any(Function)}); expect(baseChip.props.showAnimation).toBe(true); expect(baseChip.props.prefix).toBeDefined(); @@ -53,5 +54,42 @@ describe('UserChip', () => { baseChip.props.onPress(); expect(mockOnPress).toHaveBeenCalledTimes(1); expect(mockOnPress).toHaveBeenCalledWith('user-id'); + expect(mockOnActionPress).not.toHaveBeenCalled(); + + mockOnPress.mockClear(); + + baseChip.props.action.onPress(); + expect(mockOnActionPress).toHaveBeenCalledTimes(1); + expect(mockOnActionPress).toHaveBeenCalledWith('user-id'); + expect(mockOnPress).not.toHaveBeenCalled(); + }); + + it('should leave action onPress undefined when action with no onPress is provided', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const baseChip = getByTestId('user-chip'); + expect(baseChip.props.action.onPress).toBeUndefined(); + }); + + it('should leave onPress undefined when onPress is not provided', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const baseChip = getByTestId('user-chip'); + expect(baseChip.props.onPress).toBeUndefined(); }); }); diff --git a/app/components/chips/user_chip.tsx b/app/components/chips/user_chip.tsx index d57f06298..42ce98dec 100644 --- a/app/components/chips/user_chip.tsx +++ b/app/components/chips/user_chip.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useMemo} from 'react'; +import React, {useMemo} from 'react'; import {useIntl} from 'react-intl'; import ProfilePicture from '@components/profile_picture'; @@ -13,10 +13,13 @@ import type UserModel from '@typings/database/models/servers/user'; type SelectedChipProps = { user: UserModel | UserProfile; - onPress: (id: string) => void; + onPress?: (id: string) => void; testID?: string; teammateNameDisplay: string; - actionIcon?: 'remove' | 'downArrow'; + action?: { + icon: 'remove' | 'downArrow'; + onPress?: (id: string) => void; + }; showAnimation?: boolean; } @@ -25,15 +28,26 @@ export default function UserChip({ user, teammateNameDisplay, onPress: receivedOnPress, - actionIcon, + action: receivedAction, showAnimation, }: SelectedChipProps) { const intl = useIntl(); - const onPress = useCallback(() => { - receivedOnPress(user.id); + const onPress = useMemo(() => { + if (!receivedOnPress) { + return undefined; + } + return () => receivedOnPress(user.id); }, [receivedOnPress, user.id]); + const action = useMemo(() => { + if (!receivedAction) { + return undefined; + } + const onActionPress = receivedAction.onPress ? (() => receivedAction.onPress?.(user.id)) : undefined; + return {icon: receivedAction.icon, onPress: onActionPress}; + }, [receivedAction, user.id]); + const name = displayUsername(user, intl.locale, teammateNameDisplay); const picture = useMemo(() => ( { 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); + expect(ownerChip.props.action).toBe(undefined); ownerChip.props.onPress(); expect(openUserProfileModal).toHaveBeenCalledWith(expect.anything(), expect.anything(), { @@ -213,7 +213,31 @@ describe('PlaybookRun', () => { const {getByTestId} = renderWithEverything(, {database}); const ownerChip = getByTestId('user-chip'); - expect(ownerChip).toHaveProp('actionIcon', 'downArrow'); + expect(ownerChip).toHaveProp('action', {icon: 'downArrow', onPress: expect.any(Function)}); + ownerChip.props.action.onPress(); + + expect(goToSelectUser).toHaveBeenCalledWith( + 'Owner', + [...props.participants.map((p) => p.id), props.owner!.id], + props.owner!.id, + expect.any(Function), + ); + expect(openUserProfileModal).not.toHaveBeenCalled(); + + let 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(); + + jest.mocked(goToSelectUser).mockClear(); + jest.mocked(setOwner).mockClear(); + + // Test also pressing on the whole chip ownerChip.props.onPress(); expect(goToSelectUser).toHaveBeenCalledWith( @@ -224,7 +248,7 @@ describe('PlaybookRun', () => { ); expect(openUserProfileModal).not.toHaveBeenCalled(); - const handleSelect = jest.mocked(goToSelectUser).mock.calls[0][3]; + handleSelect = jest.mocked(goToSelectUser).mock.calls[0][3]; handleSelect(TestHelper.fakeUser({id: 'user-2'})); expect(setOwner).toHaveBeenCalledWith( diff --git a/app/products/playbooks/screens/playbook_run/playbook_run.tsx b/app/products/playbooks/screens/playbook_run/playbook_run.tsx index 3849ee1a7..47f4fe027 100644 --- a/app/products/playbooks/screens/playbook_run/playbook_run.tsx +++ b/app/products/playbooks/screens/playbook_run/playbook_run.tsx @@ -207,6 +207,13 @@ export default function PlaybookRun({ ); }, [handleSelectOwner, intl, owner, participants]); + const ownerAction = useMemo(() => { + if (readOnly) { + return undefined; + } + return {icon: 'downArrow' as const, onPress: openChangeOwnerModal}; + }, [openChangeOwnerModal, readOnly]); + if (!playbookRun) { return ; } @@ -255,7 +262,7 @@ export default function PlaybookRun({ user={owner} onPress={readOnly ? openOwnerProfile : openChangeOwnerModal} teammateNameDisplay={teammateNameDisplay} - actionIcon={readOnly ? undefined : 'downArrow'} + action={ownerAction} /> diff --git a/app/screens/component_library/chip.cl.tsx b/app/screens/component_library/chip.cl.tsx index e67f823e0..b528b3f07 100644 --- a/app/screens/component_library/chip.cl.tsx +++ b/app/screens/component_library/chip.cl.tsx @@ -14,6 +14,7 @@ import {buildComponent} from './utils'; const propPossibilities = {}; const onPress = () => Alert.alert('Button pressed!'); +const onActionPress = () => Alert.alert('Action pressed!'); const ChipComponentLibrary = () => { const theme = useTheme(); @@ -21,9 +22,21 @@ const ChipComponentLibrary = () => { const [actionIcon, actionIconPossibilities, actionIconSelector] = useDropdownProp('actionIcon', 'remove', ['remove', 'downArrow'], true); const [hasPrefix, hasPrefixSelector] = useBooleanProp('hasPrefix', false); + const actionPossibilities = useMemo(() => { + if (!actionIconPossibilities) { + return undefined; + } + return { + action: actionIconPossibilities.actionIcon.map((iconName) => ({ + icon: iconName, + onPress: onActionPress, + })), + }; + }, [actionIconPossibilities]); + const components = useMemo( () => buildComponent(BaseChip, propPossibilities, [ - actionIconPossibilities, + actionPossibilities, ], [ label, actionIcon, @@ -41,7 +54,7 @@ const ChipComponentLibrary = () => { theme, }, ]), - [label, actionIconPossibilities, actionIcon, hasPrefix.hasPrefix, theme], + [actionPossibilities, label, actionIcon, hasPrefix.hasPrefix, theme], ); return ( diff --git a/app/screens/component_library/utils.tsx b/app/screens/component_library/utils.tsx index dddb6ff21..052fe732b 100644 --- a/app/screens/component_library/utils.tsx +++ b/app/screens/component_library/utils.tsx @@ -40,7 +40,7 @@ function buildPropString(inputProps: {[x: string]: any}) { export function buildComponent( Component: React.ComponentType, propPossibilities: {[x: string]: any[]}, - dropdownPossibilities: Array<{[x: string]: string[]} | undefined>, + dropdownPossibilities: Array<{[x: string]: unknown[]} | undefined>, setProps: Array<{[x: string]: any} | undefined>, ) { const res: React.ReactNode[] = [];