[MM-60405] Crossteam search (#8411)

* Add new team picker for search

* try fix result header

* fix style

* add test for team picker

* add some tests

* add tests on team list and team list item

* hide All Teams search behind FF

* use style variable for separator

* ALL TEAMS does not have a search history

* Update app/components/team_list/index.test.tsx

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* move ALL_TEAMS_ID to a constant file

* memoize the construction of team list to prevent useless allocation

* combine pushes

* move style to stylesheet

* revert changes to Package.resolved

* improve team list index tests

* add test to ensure team picker does not show for just one team

* add test to ensure the file icon filter only shows when i'm on the file tab

* improve jsx readability by making the if test positive

* add test to make sure team picket does not show if there's only one team

* test: remove snapshot and add expect that separator exists in index 0 but index 1 (#8474)

* Trigger Build

---------

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
This commit is contained in:
Julien Tant 2025-02-05 13:46:38 -07:00 committed by GitHub
parent 1207b0e0cd
commit fb9760151c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 631 additions and 160 deletions

View file

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {fireEvent, renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import TeamList from './index';
import type Database from '@nozbe/watermelondb/Database';
import type TeamModel from '@typings/database/models/servers/team';
describe('TeamList', () => {
let database: Database;
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
});
const teams = [
{id: 'team1', displayName: 'Team 1'} as TeamModel,
{id: 'team2', displayName: 'Team 2'} as TeamModel,
];
it('should call onPress when a team is pressed', () => {
const onPress = jest.fn();
const {getByText} = renderWithEverything(
<TeamList
teams={teams}
onPress={onPress}
/>,
{database},
);
fireEvent.press(getByText('Team 1'));
expect(onPress).toHaveBeenCalledWith('team1');
});
it.each([
{loading: true, shouldShow: true, label: 'should render loading component when loading is true'},
{loading: false, shouldShow: false, label: 'should not render loading component when loading is false'},
])('$label', ({loading, shouldShow}) => {
const {queryByTestId} = renderWithEverything(
<TeamList
teams={teams}
onPress={jest.fn()}
loading={loading}
/>,
{database},
);
if (shouldShow) {
expect(queryByTestId('team_list.loading')).toBeTruthy();
} else {
expect(queryByTestId('team_list.loading')).toBeNull();
}
});
it('should render separator after the first item when separatorAfterFirstItem is true', () => {
const {queryByTestId, getByTestId} = renderWithEverything(
<TeamList
teams={teams}
onPress={jest.fn()}
separatorAfterFirstItem={true}
/>,
{database},
);
expect(getByTestId('team_list.separator-0')).toBeTruthy();
expect(queryByTestId('team_list.separator-1')).toBeNull();
});
});

View file

@ -3,10 +3,12 @@
import {BottomSheetFlatList} from '@gorhom/bottom-sheet';
import React, {useCallback, useMemo} from 'react';
import {type ListRenderItemInfo, StyleSheet, View} from 'react-native';
import {type ListRenderItemInfo, View} from 'react-native';
import {FlatList} from 'react-native-gesture-handler'; // Keep the FlatList from gesture handler so it works well with bottom sheet
import Loading from '@components/loading';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import TeamListItem from './team_list_item';
@ -23,15 +25,23 @@ type Props = {
testID?: string;
textColor?: string;
type?: BottomSheetList;
hideIcon?: boolean;
separatorAfterFirstItem?: boolean;
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
},
contentContainer: {
marginBottom: 4,
},
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
flexGrow: 1,
},
contentContainer: {
marginBottom: 4,
},
separator: {
height: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
},
};
});
const keyExtractor = (item: TeamModel) => item.id;
@ -47,11 +57,15 @@ export default function TeamList({
testID,
textColor,
type = 'FlatList',
hideIcon = false,
separatorAfterFirstItem = false,
}: Props) {
const List = useMemo(() => (type === 'FlatList' ? FlatList : BottomSheetFlatList), [type]);
const theme = useTheme();
const styles = getStyleSheet(theme);
const renderTeam = useCallback(({item: t}: ListRenderItemInfo<Team|TeamModel>) => {
return (
const renderTeam = useCallback(({item: t, index: i}: ListRenderItemInfo<Team|TeamModel>) => {
let teamListItem = (
<TeamListItem
onPress={onPress}
team={t}
@ -59,13 +73,24 @@ export default function TeamList({
iconBackgroundColor={iconBackgroundColor}
iconTextColor={iconTextColor}
selectedTeamId={selectedTeamId}
hideIcon={hideIcon}
/>
);
}, [textColor, iconTextColor, iconBackgroundColor, onPress, selectedTeamId]);
if (separatorAfterFirstItem && i === 0) {
teamListItem = (<>
{teamListItem}
<View
style={styles.separator}
testID={`team_list.separator-${i}`}
/>
</>);
}
return teamListItem;
}, [textColor, iconTextColor, iconBackgroundColor, onPress, selectedTeamId, hideIcon, separatorAfterFirstItem, styles.separator]);
let footer;
if (loading) {
footer = (<Loading/>);
footer = (<Loading testID='team_list.loading'/>);
}
return (

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 {renderWithIntlAndTheme, fireEvent} from '@test/intl-test-helper';
import TeamListItem from './team_list_item';
import type TeamModel from '@typings/database/models/servers/team';
describe('TeamListItem', () => {
const team = {
id: 'team_id',
displayName: 'Team Display Name',
lastTeamIconUpdatedAt: 0,
} as TeamModel;
const iconTestId = `team_sidebar.team_list.team_list_item.${team.id}.team_icon`;
it('should call onPress when pressed', () => {
const onPressMock = jest.fn();
const {getByText} = renderWithIntlAndTheme(
<TeamListItem
team={team}
onPress={onPressMock}
/>,
);
fireEvent.press(getByText('Team Display Name'));
expect(onPressMock).toHaveBeenCalledWith('team_id');
});
it('should render TeamIcon when hideIcon is false', () => {
const {getByTestId} = renderWithIntlAndTheme(
<TeamListItem
team={team}
onPress={jest.fn()}
hideIcon={false}
/>,
);
expect(getByTestId(iconTestId)).toBeTruthy();
});
it('should not render TeamIcon when hideIcon is true', () => {
const {queryByTestId} = renderWithIntlAndTheme(
<TeamListItem
team={team}
onPress={jest.fn()}
hideIcon={true}
/>,
);
expect(queryByTestId(iconTestId)).toBeNull();
});
});

View file

@ -20,6 +20,7 @@ type Props = {
iconBackgroundColor?: string;
selectedTeamId?: string;
onPress: (teamId: string) => void;
hideIcon?: boolean;
}
export const ITEM_HEIGHT = 56;
@ -50,7 +51,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
export default function TeamListItem({team, textColor, iconTextColor, iconBackgroundColor, selectedTeamId, onPress}: Props) {
export default function TeamListItem({team, textColor, iconTextColor, iconBackgroundColor, selectedTeamId, onPress, hideIcon}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
@ -58,6 +59,8 @@ export default function TeamListItem({team, textColor, iconTextColor, iconBackgr
const lastTeamIconUpdateAt = 'lastTeamIconUpdatedAt' in team ? team.lastTeamIconUpdatedAt : team.last_team_icon_update;
const teamListItemTestId = `team_sidebar.team_list.team_list_item.${team.id}`;
const showIcon = !hideIcon;
const handlePress = useCallback(() => {
onPress(team.id);
}, [team.id, onPress]);
@ -68,17 +71,19 @@ export default function TeamListItem({team, textColor, iconTextColor, iconBackgr
type='opacity'
style={styles.touchable}
>
<View style={styles.icon_container}>
<TeamIcon
id={team.id}
displayName={displayName}
lastIconUpdate={lastTeamIconUpdateAt}
selected={false}
textColor={iconTextColor || theme.centerChannelColor}
backgroundColor={iconBackgroundColor || changeOpacity(theme.centerChannelColor, 0.16)}
testID={`${teamListItemTestId}.team_icon`}
/>
</View>
{showIcon &&
<View style={styles.icon_container}>
<TeamIcon
id={team.id}
displayName={displayName}
lastIconUpdate={lastTeamIconUpdateAt}
selected={false}
textColor={iconTextColor || theme.centerChannelColor}
backgroundColor={iconBackgroundColor || changeOpacity(theme.centerChannelColor, 0.16)}
testID={`${teamListItemTestId}.team_icon`}
/>
</View>
}
<Text
style={[styles.text, Boolean(textColor) && {color: textColor}]}
numberOfLines={1}

View file

@ -35,6 +35,7 @@ import ServerErrors from './server_errors';
import SnackBar from './snack_bar';
import Sso from './sso';
import SupportedServer from './supported_server';
import Team from './team';
import Tutorial from './tutorial';
import View from './view';
import WebsocketEvents from './websocket';
@ -74,6 +75,7 @@ export {
SnackBar,
Sso,
SupportedServer,
Team,
Tutorial,
View,
WebsocketEvents,

8
app/constants/team.ts Normal file
View file

@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const ALL_TEAMS_ID = '';
export default {
ALL_TEAMS_ID,
};

View file

@ -1,9 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import TeamList from '@components/team_list';
import {ALL_TEAMS_ID} from '@constants/team';
import {useIsTablet} from '@hooks/device';
import BottomSheetContent from '@screens/bottom_sheet/content';
import {dismissBottomSheet} from '@screens/navigation';
@ -15,9 +17,11 @@ type Props = {
teamId: string;
setTeamId: (teamId: string) => void;
title: string;
crossTeamSearchEnabled: boolean;
}
export default function BottomSheetTeamList({teams, title, setTeamId, teamId}: Props) {
export default function BottomSheetTeamList({teams, title, setTeamId, teamId, crossTeamSearchEnabled}: Props) {
const intl = useIntl();
const isTablet = useIsTablet();
const showTitle = !isTablet && Boolean(teams.length);
@ -26,6 +30,14 @@ export default function BottomSheetTeamList({teams, title, setTeamId, teamId}: P
dismissBottomSheet();
}, [setTeamId]);
const teamList = useMemo(() => {
const list = [...teams];
if (crossTeamSearchEnabled) {
list.unshift({id: ALL_TEAMS_ID, displayName: intl.formatMessage({id: 'mobile.search.team.all_teams', defaultMessage: 'All teams'})} as TeamModel);
}
return list;
}, [teams, crossTeamSearchEnabled, intl]);
return (
<BottomSheetContent
showButton={false}
@ -35,10 +47,12 @@ export default function BottomSheetTeamList({teams, title, setTeamId, teamId}: P
>
<TeamList
selectedTeamId={teamId}
teams={teams}
teams={teamList}
onPress={onPress}
testID='search.select_team_slide_up.team_list'
type={isTablet ? 'FlatList' : 'BottomSheetFlatList'}
hideIcon={true}
separatorAfterFirstItem={crossTeamSearchEnabled}
/>
</BottomSheetContent>
);

View file

@ -4,7 +4,7 @@
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import compose from 'lodash/fp/compose';
import {observeCurrentTeamId} from '@queries/servers/system';
import {observeConfigBooleanValue, observeCurrentTeamId} from '@queries/servers/system';
import {queryJoinedTeams} from '@queries/servers/team';
import SearchScreen from './search';
@ -16,6 +16,7 @@ const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
return {
teamId: currentTeamId,
teams: queryJoinedTeams(database).observe(),
crossTeamSearchEnabled: observeConfigBooleanValue(database, 'FeatureFlagExperimentalCrossTeamSearch'),
};
});

View file

@ -5,6 +5,7 @@ import compose from 'lodash/fp/compose';
import {of as of$} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeTeam, queryTeamSearchHistoryByTeamId} from '@queries/servers/team';
import Initial from './initial';
@ -22,6 +23,7 @@ const enhance = withObservables(['teamId'], ({database, teamId}: EnhanceProps) =
switchMap((t) => of$(t?.displayName || '')),
distinctUntilChanged(),
),
crossTeamSearchEnabled: observeConfigBooleanValue(database, 'FeatureFlagExperimentalCrossTeamSearch'),
};
});

View file

@ -3,6 +3,8 @@
import React, {type Dispatch, type RefObject, type SetStateAction} from 'react';
import {ALL_TEAMS_ID} from '@constants/team';
import Modifiers from './modifiers';
import RecentSearches from './recent_searches';
@ -22,9 +24,11 @@ type Props = {
teamId: string;
teamName: string;
teams: TeamModel[];
crossTeamSearchEnabled: boolean;
}
const Initial = ({recentSearches, scrollEnabled, searchValue, setRecentValue, searchRef, teamId, teamName, teams, setTeamId, setSearchValue}: Props) => {
const Initial = ({recentSearches, scrollEnabled, searchValue, setRecentValue, searchRef, teamId, teamName, teams, setTeamId, setSearchValue, crossTeamSearchEnabled}: Props) => {
const showRecentSearches = Boolean(recentSearches.length) && teamId !== ALL_TEAMS_ID;
return (
<>
<Modifiers
@ -35,8 +39,9 @@ const Initial = ({recentSearches, scrollEnabled, searchValue, setRecentValue, se
teamId={teamId}
teams={teams}
scrollEnabled={scrollEnabled}
crossTeamSearchEnabled={crossTeamSearchEnabled}
/>
{Boolean(recentSearches.length) &&
{showRecentSearches &&
<RecentSearches
recentSearches={recentSearches}
setRecentValue={setRecentValue}

View file

@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useSharedValue} from 'react-native-reanimated';
import {ALL_TEAMS_ID} from '@constants/team';
import TeamPicker from '@screens/home/search/team_picker';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import Modifiers from './index';
import type {SearchRef} from '@components/search';
import type {TeamModel} from '@database/models/server';
jest.mock('@screens/home/search/team_picker', () => jest.fn(() => null));
jest.mock('./show_more', () => jest.fn(() => null));
jest.mock('@react-native-camera-roll/camera-roll', () => jest.fn());
describe('Modifiers', () => {
const scrollEnabled = useSharedValue(true);
const searchRef = React.createRef<SearchRef>();
const setSearchValue = jest.fn();
const setTeamId = jest.fn();
const teams = [{id: 'team1', displayName: 'Team 1'}, {id: 'team2', displayName: 'Team 2'}] as TeamModel[];
const renderComponent = (teamId = ALL_TEAMS_ID, teamList?: TeamModel[]) => {
return renderWithIntlAndTheme(
<Modifiers
setSearchValue={setSearchValue}
setTeamId={setTeamId}
teamId={teamId}
teams={teamList || teams}
scrollEnabled={scrollEnabled}
searchRef={searchRef}
crossTeamSearchEnabled={true}
/>,
);
};
it('should render correctly', () => {
const {getByTestId} = renderComponent();
expect(getByTestId('search.modifier.header')).toBeTruthy();
});
it('should render TeamPicker when there are multiple teams', () => {
renderComponent();
expect(TeamPicker).toHaveBeenCalled();
});
it('should not render TeamPicker when there is only one team', () => {
renderComponent(ALL_TEAMS_ID, [teams[0]]);
expect(TeamPicker).not.toHaveBeenCalled();
});
it('should render the From: and In: modifiers when a team is selected', () => {
const {getByTestId} = renderComponent('team1');
expect(getByTestId('search.modifier.from')).toBeTruthy();
expect(getByTestId('search.modifier.in')).toBeTruthy();
});
it('should not render the From: and In: modifiers when all teams are selected', () => {
const {queryByTestId} = renderComponent(ALL_TEAMS_ID);
expect(queryByTestId('search.modifier.from')).toBeNull();
expect(queryByTestId('search.modifier.in')).toBeNull();
});
});

View file

@ -4,11 +4,12 @@
import React, {type Dispatch, type RefObject, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {type IntlShape, useIntl} from 'react-intl';
import {View} from 'react-native';
import Animated, {useSharedValue, useAnimatedStyle, withTiming} from 'react-native-reanimated';
import Animated, {type SharedValue, useSharedValue, useAnimatedStyle, withTiming} from 'react-native-reanimated';
import FormattedText from '@components/formatted_text';
import {ALL_TEAMS_ID} from '@constants/team';
import {useTheme} from '@context/theme';
import TeamPickerIcon from '@screens/home/search/team_picker_icon';
import TeamPicker from '@screens/home/search/team_picker';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -19,31 +20,37 @@ import type {SearchRef} from '@components/search';
import type TeamModel from '@typings/database/models/servers/team';
const MODIFIER_LABEL_HEIGHT = 48;
const TEAM_PICKER_ICON_SIZE = 32;
const NUM_ITEMS_BEFORE_EXPAND = 4;
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
titleContainer: {
header: {
alignItems: 'center',
flexDirection: 'row',
marginTop: 20,
marginRight: 18,
marginHorizontal: 18,
},
titleContainer: {
flex: 1,
},
title: {
flex: 1,
alignItems: 'center',
paddingLeft: 18,
color: theme.centerChannelColor,
...typography('Heading', 300, 'SemiBold'),
},
teamPickerContainer: {
flex: 1,
alignItems: 'flex-end',
},
};
});
const getModifiersSectionsData = (intl: IntlShape): ModifierItem[] => {
const getModifiersSectionsData = (intl: IntlShape, teamId: string): ModifierItem[] => {
const formatMessage = intl.formatMessage;
const sectionsData = [
{
const sectionsData = [];
if (teamId !== ALL_TEAMS_ID) {
sectionsData.push({
term: 'From:',
testID: 'search.modifier.from',
description: formatMessage({id: 'mobile.search.modifier.from', defaultMessage: ' a specific user'}),
@ -51,52 +58,40 @@ const getModifiersSectionsData = (intl: IntlShape): ModifierItem[] => {
term: 'In:',
testID: 'search.modifier.in',
description: formatMessage({id: 'mobile.search.modifier.in', defaultMessage: ' a specific channel'}),
},
});
}
sectionsData.push({
term: '-',
testID: 'search.modifier.exclude',
description: formatMessage({id: 'mobile.search.modifier.exclude', defaultMessage: ' exclude search terms'}),
}, {
term: '""',
testID: 'search.modifier.phrases',
description: formatMessage({id: 'mobile.search.modifier.phrases', defaultMessage: ' messages with phrases'}),
cursorPosition: -1,
});
// {
// term: 'On:',
// testID: 'search.modifier.on',
// description: formatMessage({id: 'mobile.search.modifier.on', defaultMessage: ' a specific date'}),
// },
// {
// term: 'After:',
// testID: 'search.modifier.after',
// description: formatMessage({id: 'mobile.search.modifier.after', defaultMessage: ' after a date'}),
// }, {
// term: 'Before:',
// testID: 'search.modifier.before',
// description: formatMessage({id: 'mobile.search.modifier.before', defaultMessage: ' before a date'}),
// },
{
term: '-',
testID: 'search.modifier.exclude',
description: formatMessage({id: 'mobile.search.modifier.exclude', defaultMessage: ' exclude search terms'}),
}, {
term: '""',
testID: 'search.modifier.phrases',
description: formatMessage({id: 'mobile.search.modifier.phrases', defaultMessage: ' messages with phrases'}),
cursorPosition: -1,
},
];
return sectionsData;
};
type Props = {
scrollEnabled: Animated.SharedValue<boolean>;
scrollEnabled: SharedValue<boolean>;
searchRef: RefObject<SearchRef>;
setSearchValue: Dispatch<SetStateAction<string>>;
searchValue?: string;
setTeamId: (id: string) => void;
teamId: string;
teams: TeamModel[];
crossTeamSearchEnabled: boolean;
}
const Modifiers = ({scrollEnabled, searchValue, setSearchValue, searchRef, setTeamId, teamId, teams}: Props) => {
const Modifiers = ({scrollEnabled, searchValue, setSearchValue, searchRef, setTeamId, teamId, teams, crossTeamSearchEnabled}: Props) => {
const theme = useTheme();
const intl = useIntl();
const [showMore, setShowMore] = useState(false);
const height = useSharedValue(NUM_ITEMS_BEFORE_EXPAND * MODIFIER_LABEL_HEIGHT);
const data = useMemo(() => getModifiersSectionsData(intl), [intl]);
const data = useMemo(() => getModifiersSectionsData(intl, teamId), [intl, teamId]);
const timeoutRef = useRef<NodeJS.Timeout | undefined>();
const styles = getStyleFromTheme(theme);
@ -143,20 +138,25 @@ const Modifiers = ({scrollEnabled, searchValue, setSearchValue, searchRef, setTe
return (
<>
<View style={styles.titleContainer}>
<FormattedText
style={styles.title}
id={'screen.search.modifier.header'}
defaultMessage='Search options'
testID='search.modifier.header'
/>
<View style={styles.header}>
<View style={styles.titleContainer}>
<FormattedText
style={styles.title}
id={'screen.search.modifier.header'}
defaultMessage='Search options'
testID='search.modifier.header'
/>
</View>
{teams.length > 1 &&
<TeamPickerIcon
size={TEAM_PICKER_ICON_SIZE}
setTeamId={setTeamId}
teamId={teamId}
teams={teams}
/>
<View style={styles.teamPickerContainer}>
<TeamPicker
setTeamId={setTeamId}
teamId={teamId}
teams={teams}
crossTeamSearchEnabled={crossTeamSearchEnabled}
/>
</View>
}
</View>
<Animated.View style={animatedStyle}>

View file

@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithIntlAndTheme, fireEvent} from '@test/intl-test-helper';
import {FileFilters} from '@utils/file';
import {TabTypes} from '@utils/search';
import Header from './header';
import type TeamModel from '@typings/database/models/servers/team';
// Some subcomponents require react-native-camera-roll, which is not available in the test environment
jest.mock('@react-native-camera-roll/camera-roll', () => ({}));
describe('Header', () => {
const onTabSelect = jest.fn();
const onFilterChanged = jest.fn();
const setTeamId = jest.fn();
const teams = [
{id: 'team1', displayName: 'Team 1'},
{id: 'team2', displayName: 'Team 2'},
] as TeamModel[];
it('should render correctly', () => {
const {getByText} = renderWithIntlAndTheme(
<Header
teamId='team1'
setTeamId={setTeamId}
onTabSelect={onTabSelect}
onFilterChanged={onFilterChanged}
selectedTab={TabTypes.MESSAGES}
selectedFilter={FileFilters.ALL}
teams={teams}
crossTeamSearchEnabled={false}
/>,
);
expect(getByText('Messages')).toBeTruthy();
expect(getByText('Files')).toBeTruthy();
});
it('should call onTabSelect with MESSAGES when Messages button is pressed', () => {
const {getByText} = renderWithIntlAndTheme(
<Header
teamId='team1'
setTeamId={setTeamId}
onTabSelect={onTabSelect}
onFilterChanged={onFilterChanged}
selectedTab={TabTypes.MESSAGES}
selectedFilter={FileFilters.ALL}
teams={teams}
crossTeamSearchEnabled={false}
/>,
);
fireEvent.press(getByText('Messages'));
expect(onTabSelect).toHaveBeenCalledWith(TabTypes.MESSAGES);
});
it('should call onTabSelect with FILES when Files button is pressed', () => {
const {getByText} = renderWithIntlAndTheme(
<Header
teamId='team1'
setTeamId={setTeamId}
onTabSelect={onTabSelect}
onFilterChanged={onFilterChanged}
selectedTab={TabTypes.MESSAGES}
selectedFilter={FileFilters.ALL}
teams={teams}
crossTeamSearchEnabled={false}
/>,
);
fireEvent.press(getByText('Files'));
expect(onTabSelect).toHaveBeenCalledWith(TabTypes.FILES);
});
it('should not render TeamPicker when there is only one team', () => {
const {queryByText} = renderWithIntlAndTheme(
<Header
teamId='team1'
setTeamId={setTeamId}
onTabSelect={onTabSelect}
onFilterChanged={onFilterChanged}
selectedTab={TabTypes.MESSAGES}
selectedFilter={FileFilters.ALL}
teams={[teams[0]]}
crossTeamSearchEnabled={false}
/>,
);
expect(queryByText('Team 1')).toBeFalsy();
});
it('should render TeamPicker when there are multiple teams', () => {
const {getByText} = renderWithIntlAndTheme(
<Header
teamId='team1'
setTeamId={setTeamId}
onTabSelect={onTabSelect}
onFilterChanged={onFilterChanged}
selectedTab={TabTypes.MESSAGES}
selectedFilter={FileFilters.ALL}
teams={teams}
crossTeamSearchEnabled={false}
/>,
);
expect(getByText('Team 1')).toBeTruthy();
});
it.each([
{selectedTab: TabTypes.FILES, shouldShowIcon: true, label: 'should render the file type filter when the Files tab is selected'},
{selectedTab: TabTypes.MESSAGES, shouldShowIcon: false, label: 'should not render the file type filter when the Messages tab is selected'},
])('$label', ({selectedTab, shouldShowIcon}) => {
const {queryByTestId} = renderWithIntlAndTheme(
<Header
teamId='team1'
setTeamId={setTeamId}
onTabSelect={onTabSelect}
onFilterChanged={onFilterChanged}
selectedTab={selectedTab}
selectedFilter={FileFilters.ALL}
teams={teams}
crossTeamSearchEnabled={false}
/>,
);
if (shouldShowIcon) {
expect(queryByTestId('search.filters.file_type_icon')).toBeTruthy();
} else {
expect(queryByTestId('search.filters.file_type_icon')).toBeFalsy();
}
});
});

View file

@ -10,7 +10,7 @@ import Filter, {DIVIDERS_HEIGHT, FILTER_ITEM_HEIGHT, NUMBER_FILTER_ITEMS} from '
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {TITLE_SEPARATOR_MARGIN, TITLE_SEPARATOR_MARGIN_TABLET, TITLE_HEIGHT} from '@screens/bottom_sheet/content';
import TeamPickerIcon from '@screens/home/search/team_picker_icon';
import TeamPicker from '@screens/home/search/team_picker';
import {bottomSheet} from '@screens/navigation';
import {type FileFilter, FileFilters} from '@utils/file';
import {bottomSheetSnapPoint} from '@utils/helpers';
@ -29,13 +29,13 @@ type Props = {
setTeamId: (id: string) => void;
teamId: string;
teams: TeamModel[];
crossTeamSearchEnabled: boolean;
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
marginTop: 10,
backgroundColor: theme.centerChannelBg,
borderBottomWidth: 1,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1),
},
@ -44,15 +44,26 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
borderColor: theme.centerChannelBg,
marginTop: 2,
},
buttonsContainer: {
header: {
marginBottom: 12,
paddingHorizontal: 12,
flexDirection: 'row',
justifyContent: 'space-between',
},
iconsContainer: {
alignItems: 'center',
buttonContainer: {
flexDirection: 'row',
marginLeft: 'auto',
},
teamPickerContainer: {
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-end',
},
filterContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
maxWidth: 32,
},
};
});
@ -65,6 +76,7 @@ const Header = ({
selectedTab,
selectedFilter,
teams,
crossTeamSearchEnabled,
}: Props) => {
const theme = useTheme();
const styles = getStyleFromTheme(theme);
@ -113,50 +125,50 @@ const Header = ({
theme,
title,
});
}, [onFilterChanged, selectedFilter]);
const filterStyle = useMemo(() => ({marginRight: teams.length > 1 ? 0 : 8}), [teams.length > 1]);
}, [onFilterChanged, selectedFilter, snapPoints, title, theme]);
return (
<View style={styles.container}>
<View style={styles.buttonsContainer}>
<SelectButton
selected={selectedTab === TabTypes.MESSAGES}
onPress={handleMessagesPress}
text={messagesText}
/>
<SelectButton
selected={selectedTab === TabTypes.FILES}
onPress={handleFilesPress}
text={filesText}
/>
<View style={styles.iconsContainer}>
{showFilterIcon && (
<View style={filterStyle}>
<CompassIcon
name={'filter-variant'}
size={24}
color={changeOpacity(
theme.centerChannelColor,
0.56,
)}
onPress={handleFilterPress}
/>
<Badge
style={styles.badge}
visible={hasFilters}
testID={'search.filters.badge'}
value={-1}
/>
</View>
)}
<View style={styles.header}>
<View style={styles.buttonContainer}>
<SelectButton
selected={selectedTab === TabTypes.MESSAGES}
onPress={handleMessagesPress}
text={messagesText}
/>
<SelectButton
selected={selectedTab === TabTypes.FILES}
onPress={handleFilesPress}
text={filesText}
/>
</View>
{showFilterIcon && (
<View style={styles.filterContainer}>
<CompassIcon
name={'filter-variant'}
testID='search.filters.file_type_icon'
size={24}
color={changeOpacity(
theme.centerChannelColor,
0.56,
)}
onPress={handleFilterPress}
/>
<Badge
style={styles.badge}
visible={hasFilters}
testID={'search.filters.badge'}
value={-1}
/>
</View>
)}
<View style={styles.teamPickerContainer}>
{teams.length > 1 && (
<TeamPickerIcon
size={32}
divider={true}
<TeamPicker
setTeamId={setTeamId}
teamId={teamId}
teams={teams}
crossTeamSearchEnabled={crossTeamSearchEnabled}
/>
)}
</View>

View file

@ -18,6 +18,7 @@ import Loading from '@components/loading';
import NavigationHeader from '@components/navigation_header';
import RoundedHeaderContext from '@components/rounded_header_context';
import {Screens} from '@constants';
import {ALL_TEAMS_ID} from '@constants/team';
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
@ -51,6 +52,7 @@ const AutocompletePaddingTop = 4;
type Props = {
teamId: string;
teams: TeamModel[];
crossTeamSearchEnabled: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
@ -79,7 +81,7 @@ const getSearchParams = (terms: string, filterValue?: FileFilter) => {
const searchScreenIndex = 1;
const SearchScreen = ({teamId, teams}: Props) => {
const SearchScreen = ({teamId, teams, crossTeamSearchEnabled}: Props) => {
const nav = useNavigation();
const isFocused = useIsFocused();
const intl = useIntl();
@ -189,7 +191,10 @@ const SearchScreen = ({teamId, teams}: Props) => {
hideHeader(true);
handleLoading(true);
setLastSearchedValue(term);
addSearchToTeamSearchHistory(serverUrl, newSearchTeamId, term);
if (newSearchTeamId !== ALL_TEAMS_ID) {
addSearchToTeamSearchHistory(serverUrl, newSearchTeamId, term);
}
const [postResults, {files, channels}] = await Promise.all([
searchPosts(serverUrl, newSearchTeamId, searchParams),
searchFiles(serverUrl, newSearchTeamId, searchParams),
@ -396,6 +401,7 @@ const SearchScreen = ({teamId, teams}: Props) => {
selectedTab={selectedTab}
selectedFilter={filter}
teams={teams}
crossTeamSearchEnabled={crossTeamSearchEnabled}
/>
}
</Animated.View>

View file

@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {ALL_TEAMS_ID} from '@constants/team';
import {bottomSheet} from '@screens/navigation';
import {fireEvent, renderWithIntlAndTheme} from '@test/intl-test-helper';
import TeamPicker from './team_picker';
import type {TeamModel} from '@database/models/server';
jest.mock('@screens/navigation', () => ({
bottomSheet: jest.fn(),
}));
// Some subcomponents require react-native-camera-roll, which is not available in the test environment
jest.mock('@react-native-camera-roll/camera-roll', () => ({}));
describe('TeamPicker', () => {
const teams = [
{id: 'team1', displayName: 'Team 1'} as TeamModel,
{id: 'team2', displayName: 'Team 2'} as TeamModel,
];
it('should render the selected team name', () => {
const {getByText} = renderWithIntlAndTheme(
<TeamPicker
setTeamId={jest.fn()}
teams={teams}
teamId={'team1'}
crossTeamSearchEnabled={true}
/>,
);
expect(getByText('Team 1')).toBeTruthy();
});
it('should render "All teams" when teamId is ALL_TEAMS_ID', () => {
const {getByText} = renderWithIntlAndTheme(
<TeamPicker
setTeamId={jest.fn()}
teams={teams}
teamId={ALL_TEAMS_ID}
crossTeamSearchEnabled={true}
/>,
);
expect(getByText('All teams')).toBeTruthy();
});
it('should call bottomSheet when the team picker is pressed', () => {
const {getByTestId} = renderWithIntlAndTheme(
<TeamPicker
setTeamId={jest.fn()}
teams={teams}
teamId={'team1'}
crossTeamSearchEnabled={true}
/>,
);
fireEvent.press(getByTestId('team_picker.button'));
expect(bottomSheet).toHaveBeenCalled();
});
});

View file

@ -3,12 +3,12 @@
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import {Text, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import TeamIcon from '@components/team_sidebar/team_list/team_item/team_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ALL_TEAMS_ID} from '@constants/team';
import {useTheme} from '@context/theme';
import {TITLE_HEIGHT} from '@screens/bottom_sheet';
import {bottomSheet} from '@screens/navigation';
@ -25,39 +25,38 @@ const NO_TEAMS_HEIGHT = 392;
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
teamContainer: {
paddingLeft: 8,
teamPicker: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
width: '100%',
},
border: {
marginLeft: 12,
borderLeftWidth: 1,
borderLeftColor: changeOpacity(theme.centerChannelColor, 0.16),
container: {
flex: 1,
},
teamIcon: {
flexDirection: 'row',
},
compass: {
alignItems: 'center',
marginLeft: 0,
teamName: {
color: theme.centerChannelColor,
fontSize: 12,
textAlign: 'right',
},
};
});
type Props = {
size?: number;
divider?: boolean;
teams: TeamModel[];
setTeamId: (id: string) => void;
teamId: string;
crossTeamSearchEnabled: boolean;
}
const TeamPickerIcon = ({size = 24, divider = false, setTeamId, teams, teamId}: Props) => {
const TeamPicker = ({setTeamId, teams, teamId, crossTeamSearchEnabled}: Props) => {
const intl = useIntl();
const theme = useTheme();
const styles = getStyleFromTheme(theme);
const selectedTeam = teams.find((t) => t.id === teamId);
let selectedTeam = teams.find((t) => t.id === teamId);
if (teamId === ALL_TEAMS_ID) {
selectedTeam = {id: ALL_TEAMS_ID, displayName: intl.formatMessage({id: 'mobile.search.team.all_teams', defaultMessage: 'All teams'})} as TeamModel;
}
const title = intl.formatMessage({id: 'mobile.search.team.select', defaultMessage: 'Select a team to search'});
@ -69,6 +68,7 @@ const TeamPickerIcon = ({size = 24, divider = false, setTeamId, teams, teamId}:
teams={teams}
teamId={teamId}
title={title}
crossTeamSearchEnabled={crossTeamSearchEnabled}
/>
);
};
@ -98,23 +98,18 @@ const TeamPickerIcon = ({size = 24, divider = false, setTeamId, teams, teamId}:
onPress={handleTeamChange}
type='opacity'
testID='team_picker.button'
style={styles.teamPicker}
>
<View style={[styles.teamContainer, divider && styles.border]}>
<View style={[styles.teamIcon, {width: size, height: size}]}>
<TeamIcon
displayName={selectedTeam.displayName}
id={selectedTeam.id}
lastIconUpdate={selectedTeam.lastTeamIconUpdatedAt}
textColor={theme.centerChannelColor}
backgroundColor={changeOpacity(theme.centerChannelColor, 0.16)}
selected={false}
testID={`team_picker.${selectedTeam.id}.team_icon`}
smallText={true}
/>
</View>
<View style={styles.container}>
<Text
id={selectedTeam.id}
numberOfLines={1}
style={styles.teamName}
>{selectedTeam.displayName}</Text>
</View>
<View>
<CompassIcon
color={changeOpacity(theme.centerChannelColor, 0.56)}
style={styles.compass}
name='menu-down'
size={MENU_DOWN_ICON_SIZE}
/>
@ -124,4 +119,4 @@ const TeamPickerIcon = ({size = 24, divider = false, setTeamId, teams, teamId}:
</>
);
};
export default TeamPickerIcon;
export default TeamPicker;

View file

@ -787,6 +787,7 @@
"mobile.search.results": "{count} search {count, plural, one {result} other {results}}",
"mobile.search.show_less": "Show less",
"mobile.search.show_more": "Show more",
"mobile.search.team.all_teams": "All teams",
"mobile.search.team.select": "Select a team to search",
"mobile.server_identifier.exists": "You are already connected to this server.",
"mobile.server_link.error.text": "The link could not be found on this server.",

View file

@ -121,6 +121,7 @@ interface ClientConfig {
FeatureFlagCollapsedThreads?: string;
FeatureFlagPostPriority?: string;
FeatureFlagChannelBookmarks?: string;
FeatureFlagExperimentalCrossTeamSearch?: string;
FeatureFlagCustomProfileAttributes?: string;
ForgotPasswordLink?: string;
GfycatApiKey: string;