Refactor chips (#8740)

* Refactor chips

* Fix tsc

* Rename showXButton
This commit is contained in:
Daniel Espino García 2025-04-08 10:14:01 +02:00 committed by GitHub
parent 738c7e6b1d
commit 1c0e201edf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 461 additions and 133 deletions

View file

@ -1,9 +1,6 @@
// 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 {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';

View file

@ -0,0 +1,111 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent} from '@testing-library/react-native';
import React from 'react';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import BaseChip from './base_chip';
describe('BaseChip', () => {
const onPressMock = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
it('should render correctly with default props', () => {
const {getByText} = renderWithIntlAndTheme(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
/>,
);
expect(getByText('Test Label')).toBeTruthy();
});
it('should render with the X button when showRemoveOption is true', () => {
const {getByTestId} = renderWithIntlAndTheme(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
showRemoveOption={true}
/>,
);
expect(getByTestId('base_chip.remove.button')).toBeTruthy();
});
it('should not render the X button when showRemoveOption is false', () => {
const {queryByTestId} = renderWithIntlAndTheme(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
showRemoveOption={false}
/>,
);
expect(queryByTestId('base_chip.remove.button')).toBeNull();
});
it('should call onPress when the X button is pressed', () => {
const {getByTestId} = renderWithIntlAndTheme(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
showRemoveOption={true}
/>,
);
fireEvent.press(getByTestId('base_chip.remove.button'));
expect(onPressMock).toHaveBeenCalledTimes(1);
});
it('should call onPress when the chip is pressed without X button', () => {
const {getByTestId} = renderWithIntlAndTheme(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
showRemoveOption={false}
/>,
);
fireEvent.press(getByTestId('base_chip.chip_button'));
expect(onPressMock).toHaveBeenCalledTimes(1);
});
it('should handle animations when showAnimation is true', () => {
const {getByTestId} = renderWithIntlAndTheme(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
showAnimation={true}
/>,
);
expect(getByTestId('base_chip').props.entering).toBeTruthy();
expect(getByTestId('base_chip').props.exiting).toBeTruthy();
});
it('should not handle animations when showAnimation is false', () => {
const {getByTestId} = renderWithIntlAndTheme(
<BaseChip
onPress={onPressMock}
label='Test Label'
testID='base_chip'
showAnimation={false}
/>,
);
expect(getByTestId('base_chip').props.entering).toBeUndefined();
expect(getByTestId('base_chip').props.exiting).toBeUndefined();
});
});

View file

@ -1,8 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {Text, TouchableOpacity, useWindowDimensions, type StyleProp, type ViewStyle} from 'react-native';
import React from 'react';
import {Text, TouchableOpacity, useWindowDimensions} from 'react-native';
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
@ -11,17 +11,17 @@ import {nonBreakingString} from '@utils/strings';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {CHIP_BOTTOM_MARGIN, CHIP_HEIGHT} from './constants';
type SelectedChipProps = {
id: string;
text: string;
extra?: React.ReactNode;
onRemove: (id: string) => void;
onPress: () => void;
testID?: string;
containerStyle?: StyleProp<ViewStyle>;
showRemoveOption?: boolean;
showAnimation?: boolean;
label: string;
prefix?: JSX.Element;
}
export const USER_CHIP_HEIGHT = 32;
export const USER_CHIP_BOTTOM_MARGIN = 8;
const FADE_DURATION = 100;
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
@ -31,9 +31,9 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
justifyContent: 'center',
flexDirection: 'row',
borderRadius: 16,
height: USER_CHIP_HEIGHT,
height: CHIP_HEIGHT,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
marginBottom: USER_CHIP_BOTTOM_MARGIN,
marginBottom: CHIP_BOTTOM_MARGIN,
marginRight: 8,
paddingHorizontal: 7,
},
@ -49,50 +49,68 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
};
});
export default function SelectedChip({
id,
text,
extra,
onRemove,
export default function BaseChip({
testID,
containerStyle,
onPress,
showRemoveOption,
showAnimation,
label,
prefix,
}: SelectedChipProps) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
const dimensions = useWindowDimensions();
const containerStyles = [style.container, containerStyle];
const onPress = useCallback(() => {
onRemove(id);
}, [onRemove, id]);
return (
<Animated.View
entering={FadeIn.duration(FADE_DURATION)}
exiting={FadeOut.duration(FADE_DURATION)}
style={containerStyles}
testID={testID}
>
{extra}
const chipContent = (
<>
{prefix}
<Text
style={[style.text, {maxWidth: dimensions.width * 0.70}]}
numberOfLines={1}
testID={`${testID}.display_name`}
>
{nonBreakingString(text)}
{nonBreakingString(label)}
</Text>
</>
);
let content;
if (showRemoveOption) {
content = (
<>
{chipContent}
<TouchableOpacity
style={style.remove}
onPress={onPress}
testID={`${testID}.remove.button`}
>
<CompassIcon
name='close-circle'
size={18}
color={changeOpacity(theme.centerChannelColor, 0.32)}
/>
</TouchableOpacity>
</>
);
} else {
content = (
<TouchableOpacity
style={style.remove}
onPress={onPress}
testID={`${testID}.remove.button`}
testID={`${testID}.chip_button`}
>
<CompassIcon
name='close-circle'
size={18}
color={changeOpacity(theme.centerChannelColor, 0.32)}
/>
{chipContent}
</TouchableOpacity>
);
}
return (
<Animated.View
entering={showAnimation ? FadeIn.duration(FADE_DURATION) : undefined}
exiting={showAnimation ? FadeOut.duration(FADE_DURATION) : undefined}
style={style.container}
testID={testID}
>
{content}
</Animated.View>
);
}

View file

@ -0,0 +1,5 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const CHIP_HEIGHT = 32;
export const CHIP_BOTTOM_MARGIN = 8;

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render} from '@testing-library/react-native';
import React from 'react';
import BaseChip from './base_chip';
import SelectedChip from './selected_chip';
jest.mock('./base_chip', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(BaseChip).mockImplementation((props) => React.createElement('BaseChip', {...props}));
describe('SelectedChip', () => {
const mockOnRemove = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should render with the correct props', () => {
const {getByTestId} = render(
<SelectedChip
id='test-id'
text='Test Chip'
onRemove={mockOnRemove}
testID='selected-chip'
/>,
);
const baseChip = getByTestId('selected-chip');
expect(baseChip.props.label).toBe('Test Chip');
expect(baseChip.props.showRemoveOption).toBe(true);
expect(baseChip.props.showAnimation).toBe(true);
expect(baseChip.props.prefix).toBeUndefined();
baseChip.props.onPress();
expect(mockOnRemove).toHaveBeenCalledTimes(1);
expect(mockOnRemove).toHaveBeenCalledWith('test-id');
});
});

View file

@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import BaseChip from './base_chip';
type SelectedChipProps = {
id: string;
text: string;
onRemove: (id: string) => void;
testID?: string;
}
export default function SelectedChip({
id,
text,
onRemove,
testID,
}: SelectedChipProps) {
const onPress = useCallback(() => {
onRemove(id);
}, [onRemove, id]);
return (
<BaseChip
testID={testID}
onPress={onPress}
showRemoveOption={true}
showAnimation={true}
label={text}
/>
);
}

View file

@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render} from '@testing-library/react-native';
import React from 'react';
import TestHelper from '@test/test_helper';
import SelectedUserChip from './selected_user_chip';
import UserChip from './user_chip';
jest.mock('./user_chip', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(UserChip).mockImplementation((props) => React.createElement('UserChip', {...props}));
describe('SelectedUserChip', () => {
const mockOnPress = jest.fn();
const mockUser = TestHelper.fakeUser({id: 'user-id', username: 'test-user'});
it('should render with the correct props', () => {
const {getByTestId} = render(
<SelectedUserChip
user={mockUser}
onPress={mockOnPress}
testID='selected-user-chip'
teammateNameDisplay='username'
/>,
);
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.showAnimation).toBe(true);
userChip.props.onPress();
expect(mockOnPress).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import UserChip from './user_chip';
import type UserModel from '@typings/database/models/servers/user';
type SelectedChipProps = {
user: UserModel | UserProfile;
onPress: (id: string) => void;
testID?: string;
teammateNameDisplay: string;
}
export default function SelectedUserChip({
testID,
user,
teammateNameDisplay,
onPress,
}: SelectedChipProps) {
return (
<UserChip
testID={testID}
onPress={onPress}
showRemoveOption={true}
showAnimation={true}
teammateNameDisplay={teammateNameDisplay}
user={user}
/>
);
}

View file

@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import ProfilePicture from '@components/profile_picture';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import BaseChip from './base_chip';
import UserChip from './user_chip';
jest.mock('./base_chip', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(BaseChip).mockImplementation((props) => React.createElement('BaseChip', {...props}));
jest.mock('@components/profile_picture', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(ProfilePicture).mockImplementation((props) => React.createElement('ProfilePicture', {...props}));
describe('UserChip', () => {
const mockOnPress = jest.fn();
const mockUser = TestHelper.fakeUser({id: 'user-id', username: 'test-user'});
it('should render with the correct props', () => {
const {getByTestId} = renderWithIntlAndTheme(
<UserChip
user={mockUser}
onPress={mockOnPress}
testID='user-chip'
teammateNameDisplay='username'
showRemoveOption={true}
showAnimation={true}
/>,
);
const baseChip = getByTestId('user-chip');
expect(baseChip.props.label).toBe('test-user');
expect(baseChip.props.showRemoveOption).toBe(true);
expect(baseChip.props.showAnimation).toBe(true);
expect(baseChip.props.prefix).toBeDefined();
const profilePicture = baseChip.props.prefix;
expect(profilePicture.props.author).toBe(mockUser);
expect(profilePicture.props.size).toBe(20);
expect(profilePicture.props.iconSize).toBe(20);
expect(profilePicture.props.testID).toBe('user-chip.profile_picture');
baseChip.props.onPress();
expect(mockOnPress).toHaveBeenCalledTimes(1);
expect(mockOnPress).toHaveBeenCalledWith('user-id');
});
});

View file

@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import ProfilePicture from '@components/profile_picture';
import {displayUsername} from '@utils/user';
import BaseChip from './base_chip';
import type UserModel from '@typings/database/models/servers/user';
type SelectedChipProps = {
user: UserModel | UserProfile;
onPress: (id: string) => void;
testID?: string;
teammateNameDisplay: string;
showRemoveOption?: boolean;
showAnimation?: boolean;
}
export default function UserChip({
testID,
user,
teammateNameDisplay,
onPress: receivedOnPress,
showRemoveOption,
showAnimation,
}: SelectedChipProps) {
const intl = useIntl();
const onPress = useCallback(() => {
receivedOnPress(user.id);
}, [receivedOnPress, user.id]);
const name = displayUsername(user, intl.locale, teammateNameDisplay);
const picture = useMemo(() => (
<ProfilePicture
author={user}
size={20}
iconSize={20}
testID={`${testID}.profile_picture`}
/>
), [testID, user]);
return (
<BaseChip
testID={testID}
onPress={onPress}
showRemoveOption={showRemoveOption}
showAnimation={showAnimation}
label={name}
prefix={picture}
/>
);
}

View file

@ -31,8 +31,9 @@ import Animated, {
Easing,
} from 'react-native-reanimated';
import {CHIP_HEIGHT} from '@components/chips/constants';
import SelectedChip from '@components/chips/selected_chip';
import CompassIcon from '@components/compass_icon';
import SelectedChip, {USER_CHIP_HEIGHT} from '@components/selected_chip';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -67,7 +68,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
paddingHorizontal: 0,
paddingTop: 0,
paddingBottom: 0,
height: USER_CHIP_HEIGHT,
height: CHIP_HEIGHT,
flexGrow: 1,
flexShrink: 0,
flexBasis: 'auto',
@ -222,7 +223,7 @@ const FloatingTextChipsInput = forwardRef<Ref, Props>(({
}
res.push({
borderWidth: focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH,
minHeight: (USER_CHIP_HEIGHT * 2.5) + ((focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH) * 2),
minHeight: (CHIP_HEIGHT * 2.5) + ((focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH) * 2),
});
if (focused) {
@ -282,7 +283,6 @@ const FloatingTextChipsInput = forwardRef<Ref, Props>(({
text={chipValue}
testID={`${testID}.${chipValue}`}
onRemove={onChipRemove}
containerStyle={styles.chipContainer}
/>
))}
<TextInput

View file

@ -7,14 +7,13 @@ import Animated, {useAnimatedStyle, useDerivedValue, useSharedValue, withTiming}
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import Button from '@components/button';
import {USER_CHIP_BOTTOM_MARGIN, USER_CHIP_HEIGHT} from '@components/selected_chip';
import {CHIP_BOTTOM_MARGIN, CHIP_HEIGHT} from '@components/chips/constants';
import SelectedUserChip from '@components/chips/selected_user_chip';
import Toast from '@components/toast';
import {useTheme} from '@context/theme';
import {useIsTablet, useKeyboardHeightWithDuration} from '@hooks/device';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import SelectedUser from './selected_user';
type Props = {
/**
@ -84,8 +83,8 @@ type Props = {
}
const BUTTON_HEIGHT = 48;
const CHIP_HEIGHT_WITH_MARGIN = USER_CHIP_HEIGHT + USER_CHIP_BOTTOM_MARGIN;
const EXPOSED_CHIP_HEIGHT = 0.33 * USER_CHIP_HEIGHT;
const CHIP_HEIGHT_WITH_MARGIN = CHIP_HEIGHT + CHIP_BOTTOM_MARGIN;
const EXPOSED_CHIP_HEIGHT = 0.33 * CHIP_HEIGHT;
const MAX_CHIP_ROWS = 2;
const SCROLL_MARGIN_TOP = 20;
const SCROLL_MARGIN_BOTTOM = 12;
@ -164,18 +163,20 @@ export default function SelectedUsers({
const users = useMemo(() => {
const u = [];
for (const [id, user] of Object.entries(selectedIds)) {
for (const user of Object.values(selectedIds)) {
if (!user) {
continue;
}
const userItemTestID = `${testID}.${user.id}`;
u.push(
<SelectedUser
key={id}
<SelectedUserChip
key={user.id}
user={user}
onPress={onRemove}
teammateNameDisplay={teammateNameDisplay}
onRemove={onRemove}
testID={`${testID}.selected_user`}
testID={userItemTestID}
/>,
);
}

View file

@ -1,66 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import ProfilePicture from '@components/profile_picture';
import SelectedChip from '@components/selected_chip';
import {displayUsername} from '@utils/user';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
/*
* How to display the names of users.
*/
teammateNameDisplay: string;
/*
* The user that this component represents.
*/
user: UserProfile|UserModel;
/*
* A handler function that will deselect a user when clicked on.
*/
onRemove: (id: string) => void;
/*
* The test ID.
*/
testID?: string;
}
export default function SelectedUser({
teammateNameDisplay,
user,
onRemove,
testID,
}: Props) {
const intl = useIntl();
const onPress = useCallback((id: string) => {
onRemove(id);
}, [onRemove]);
const userItemTestID = `${testID}.${user.id}`;
return (
<SelectedChip
id={user.id}
text={displayUsername(user, intl.locale, teammateNameDisplay)}
extra={(
<ProfilePicture
author={user}
size={20}
iconSize={20}
testID={`${userItemTestID}.profile_picture`}
/>
)}
onRemove={onPress}
testID={userItemTestID}
/>
);
}

View file

@ -8,11 +8,11 @@ import {Platform, type StyleProp, Text, type TextStyle, TouchableOpacity, View,
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {usePreventDoubleTap} from '@hooks/utils';
import {BOTTOM_SHEET_ANDROID_OFFSET} from '@screens/bottom_sheet';
import {TITLE_HEIGHT} from '@screens/bottom_sheet/content';
import {bottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -25,7 +25,7 @@ const OVERFLOW_DISPLAY_LIMIT = 99;
const USER_ROW_HEIGHT = 40;
type Props = {
channelId: string;
channelId?: string;
location: string;
users: UserModel[];
breakAt?: number;
@ -100,10 +100,11 @@ const UserAvatarsStack = ({
overflowTextStyle,
}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const intl = useIntl();
const isTablet = useIsTablet();
const showParticipantsList = useCallback(preventDoubleTap(() => {
const showParticipantsList = usePreventDoubleTap(useCallback(() => {
const renderContent = () => (
<>
{!isTablet && (
@ -141,13 +142,11 @@ const UserAvatarsStack = ({
title: intl.formatMessage({id: 'mobile.participants.header', defaultMessage: 'Thread Participants'}),
theme,
});
}), [isTablet, theme, users, channelId, location]);
}, [users, intl, theme, isTablet, style.listHeader, style.listHeaderText, channelId, location]));
const displayUsers = users.slice(0, breakAt);
const overflowUsersCount = Math.min(users.length - displayUsers.length, OVERFLOW_DISPLAY_LIMIT);
const style = getStyleSheet(theme);
return (
<TouchableOpacity
onPress={showParticipantsList}

View file

@ -16,14 +16,14 @@ import {dismissBottomSheet, openAsBottomSheet} from '@screens/navigation';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
channelId: string;
channelId?: string;
location: string;
type?: BottomSheetList;
users: UserModel[];
};
type ItemProps = {
channelId: string;
channelId?: string;
location: string;
user: UserModel;
}

View file

@ -14,8 +14,8 @@ import {
} from 'react-native';
import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimated';
import SelectedChip from '@components/selected_chip';
import SelectedUser from '@components/selected_users/selected_user';
import SelectedChip from '@components/chips/selected_chip';
import SelectedUserChip from '@components/chips/selected_user_chip';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import UserItem from '@components/user_item';
import {MAX_LIST_HEIGHT, MAX_LIST_TABLET_DIFF} from '@constants/autocomplete';
@ -267,11 +267,11 @@ export default function Selection({
testID={`invite.selected_item.${selectedItem}`}
/>
) : (
<SelectedUser
<SelectedUserChip
key={id}
user={selectedItem}
teammateNameDisplay={teammateNameDisplay}
onRemove={handleOnRemoveItem}
onPress={handleOnRemoveItem}
testID='invite.selected_item'
/>
));