Add onPress on full chip (#9134)

* Add onPress on full chip

* Address feedback

* Fix test
This commit is contained in:
Daniel Espino García 2025-09-22 12:07:49 +02:00 committed by GitHub
parent 3c6ae12634
commit d4fb05d4bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 191 additions and 57 deletions

View file

@ -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(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
actionIcon='remove'
action={{icon: 'remove'}}
/>,
);
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(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
actionIcon='downArrow'
action={{icon: 'downArrow'}}
/>,
);
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(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
actionIcon={undefined}
/>,
);
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(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
actionIcon='remove'
action={{icon: 'remove', onPress: onActionPressMock}}
/>,
);
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(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
actionIcon={undefined}
/>,
);
@ -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(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
action={{icon: 'remove', onPress: onActionPressMock}}
/>,
);
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(
<BaseChip

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {Platform, Text, TouchableOpacity} from 'react-native';
import {Platform, Text, TouchableOpacity, View} from 'react-native';
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
@ -17,7 +17,10 @@ import {CHIP_HEIGHT} from './constants';
type SelectedChipProps = {
onPress?: () => 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 = (
<CompassIcon
name={iconName}
size={16}
color={changeOpacity(theme.centerChannelColor, 0.64)}
testID={`${testID}.${actionIcon}.icon`}
/>
);
if (actionOnPress) {
icon = (
<TouchableOpacity
style={style.remove}
onPress={onPress}
onPress={actionOnPress}
testID={`${testID}.${actionIcon}.button`}
>
<CompassIcon
name={iconName}
size={16}
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
{icon}
</TouchableOpacity>
);
} else {
icon = (
<View style={style.remove}>
{icon}
</View>
);
}
content = (
<>
{content}
{icon}
</>
);
} else if (onPress) {
}
if (onPress) {
content = (
<TouchableOpacity
style={style.chipContent}
onPress={onPress}
testID={`${testID}.chip_button`}
>
{chipContent}
{content}
</TouchableOpacity>
);
}

View file

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

View file

@ -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 (
<BaseChip
testID={testID}
onPress={onPress}
actionIcon='remove'
action={action}
showAnimation={true}
label={text}
/>

View file

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

View file

@ -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 (
<UserChip
testID={testID}
onPress={onPress}
actionIcon='remove'
action={action}
showAnimation={true}
teammateNameDisplay={teammateNameDisplay}
user={user}

View file

@ -24,6 +24,7 @@ jest.mocked(ProfilePicture).mockImplementation((props) => 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(
<UserChip
user={mockUser}
onPress={mockOnPress}
teammateNameDisplay='username'
action={{icon: 'remove'}}
testID='user-chip'
/>,
);
const baseChip = getByTestId('user-chip');
expect(baseChip.props.action.onPress).toBeUndefined();
});
it('should leave onPress undefined when onPress is not provided', () => {
const {getByTestId} = renderWithIntlAndTheme(
<UserChip
user={mockUser}
teammateNameDisplay='username'
action={{icon: 'remove', onPress: mockOnActionPress}}
testID='user-chip'
/>,
);
const baseChip = getByTestId('user-chip');
expect(baseChip.props.onPress).toBeUndefined();
});
});

View file

@ -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(() => (
<ProfilePicture
@ -49,7 +63,7 @@ export default function UserChip({
<BaseChip
testID={testID}
onPress={onPress}
actionIcon={actionIcon}
action={action}
showAnimation={showAnimation}
label={name}
prefix={picture}

View file

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

View file

@ -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 <ErrorState/>;
}
@ -255,7 +262,7 @@ export default function PlaybookRun({
user={owner}
onPress={readOnly ? openOwnerProfile : openChangeOwnerModal}
teammateNameDisplay={teammateNameDisplay}
actionIcon={readOnly ? undefined : 'downArrow'}
action={ownerAction}
/>
</View>
</View>

View file

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

View file

@ -40,7 +40,7 @@ function buildPropString(inputProps: {[x: string]: any}) {
export function buildComponent(
Component: React.ComponentType<any>,
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[] = [];