Standardize tabs across different components (#8691)

* Standardize tabs across different components

* Add tests and minor fixes

* Remove unneeded tests

* Add missing change

* Fix test

* Refactor to remove the component from the hook

* Rename hasDot for requiresUserAttention.

* Apply the changes to scheduled posts

* Fix texts

* Fix tests and fix minor style issue on iOS

* Fix filter positioning

* Fix some e2e tests

* Fix tests

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Daniel Espino García
2025-05-28 16:23:37 +02:00
committed by GitHub
parent db569fe2c3
commit 6c53533080
30 changed files with 1081 additions and 606 deletions

View File

@@ -2,14 +2,14 @@
// See LICENSE.txt for license information.
import {createElement, isValidElement} from 'react';
import {useIntl} from 'react-intl';
import {useIntl, type MessageDescriptor} from 'react-intl';
import {type StyleProp, Text, type TextProps, type TextStyle} from 'react-native';
import {generateId} from '@utils/general';
type FormattedTextProps = TextProps & {
id: string;
defaultMessage?: string;
id?: MessageDescriptor['id'];
defaultMessage?: MessageDescriptor['defaultMessage'];
values?: Record<string, any>;
testID?: string;
style?: StyleProp<TextStyle>;

View File

@@ -8,6 +8,6 @@ export type DraftType = typeof DRAFT_TYPE_DRAFT | typeof DRAFT_TYPE_SCHEDULED;
export const DRAFT_SCHEDULED_POST_LAYOUT_PADDING = 40;
export const DRAFT_SCREEN_TAB_DRAFTS = 0;
export const DRAFT_SCREEN_TAB_SCHEDULED_POSTS = 1;
export const DRAFT_SCREEN_TAB_DRAFTS = 'drafts' as const;
export const DRAFT_SCREEN_TAB_SCHEDULED_POSTS = 'scheduled_posts' as const;
export type DraftScreenTab = typeof DRAFT_SCREEN_TAB_DRAFTS | typeof DRAFT_SCREEN_TAB_SCHEDULED_POSTS;

View File

@@ -0,0 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import useTabs from './use_tabs';
import type {TabDefinition} from './types';
export default useTabs;
export type {TabDefinition};

View File

@@ -0,0 +1,110 @@
// 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 {Preferences} from '@constants';
import {renderWithIntl} from '@test/intl-test-helper';
import Tab from './tab';
describe('Tab', () => {
const baseProps = {
name: {
id: 'test.tab',
defaultMessage: 'Test Tab',
},
id: 'test',
handleTabChange: jest.fn(),
isSelected: false,
testID: 'tabs',
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', () => {
const {getByText} = renderWithIntl(
<Tab
{...baseProps}
/>,
);
expect(getByText('Test Tab')).toBeTruthy();
});
it('shows selected state', () => {
const {getByText} = renderWithIntl(
<Tab
{...baseProps}
isSelected={true}
/>,
);
const text = getByText('Test Tab');
expect(text).toHaveStyle({color: Preferences.THEMES.denim.buttonBg});
});
it('shows dot indicator when hasDot is true', () => {
const {getByTestId} = renderWithIntl(
<Tab
{...baseProps}
requiresUserAttention={true}
/>,
);
expect(getByTestId('tabs.test.dot')).toBeTruthy();
});
it('does not show dot indicator when hasDot is false', () => {
const {queryByTestId} = renderWithIntl(
<Tab
{...baseProps}
requiresUserAttention={false}
/>,
);
expect(queryByTestId('tabs.test.dot')).toBeNull();
});
it('calls handleTabChange when pressed', () => {
const handleTabChange = jest.fn();
const {getByTestId} = renderWithIntl(
<Tab
{...baseProps}
handleTabChange={handleTabChange}
/>,
);
const button = getByTestId('tabs.test.button');
fireEvent.press(button);
expect(handleTabChange).toHaveBeenCalledWith('test');
expect(handleTabChange).toHaveBeenCalledTimes(1);
});
it('should use provided count', () => {
const {getByTestId} = renderWithIntl(
<Tab
{...baseProps}
count={1}
/>,
);
expect(getByTestId('tabs.test.badge')).toBeTruthy();
expect(getByTestId('tabs.test.badge')).toHaveTextContent('1');
});
it('should not show badge when count is 0', () => {
const {queryByTestId} = renderWithIntl(
<Tab
{...baseProps}
count={0}
/>,
);
expect(queryByTestId('tabs.test.badge')).toBeNull();
});
});

121
app/hooks/use_tabs/tab.tsx Normal file
View File

@@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {TouchableOpacity, View} from 'react-native';
import Badge from '@components/badge';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {MessageDescriptor} from 'react-intl';
const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme) => {
return {
menuItemContainer: {
paddingVertical: 8,
paddingHorizontal: 16,
flexDirection: 'row',
gap: 4,
},
menuItemContainerSelected: {
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
borderRadius: 4,
},
menuItem: {
color: changeOpacity(theme.centerChannelColor, 0.56),
alignSelf: 'center',
...typography('Body', 200, 'SemiBold'),
},
menuItemSelected: {
color: theme.buttonBg,
},
dot: {
position: 'absolute',
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.sidebarTextActiveBorder,
right: 8,
top: 8,
},
badge: {
position: undefined,
color: changeOpacity(theme.centerChannelColor, 0.75),
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
alignSelf: 'center',
left: undefined,
top: undefined,
borderWidth: 0,
...typography('Body', 100, 'SemiBold'),
},
};
});
type TabProps<T extends string> = {
name: MessageDescriptor;
id: T;
requiresUserAttention?: boolean;
handleTabChange: (value: T) => void;
isSelected: boolean;
count?: number;
testID: string;
}
const Tab = <T extends string>({
name,
id,
requiresUserAttention,
handleTabChange,
isSelected,
count,
testID,
}: TabProps<T>) => {
const theme = useTheme();
const styles = getStyleSheetFromTheme(theme);
const onPress = useCallback(() => {
handleTabChange(id);
}, [handleTabChange, id]);
const containerStyle = useMemo(() => {
return isSelected ? [styles.menuItemContainer, styles.menuItemContainerSelected] : styles.menuItemContainer;
}, [isSelected, styles.menuItemContainer, styles.menuItemContainerSelected]);
const textStyle = useMemo(() => {
return isSelected ? [styles.menuItem, styles.menuItemSelected] : styles.menuItem;
}, [isSelected, styles.menuItem, styles.menuItemSelected]);
return (
<TouchableOpacity
key={id}
onPress={onPress}
testID={`${testID}.${id}.button`}
accessibilityState={{selected: isSelected}}
>
<View style={containerStyle}>
<FormattedText
{...name}
style={textStyle}
/>
{count ? (
<Badge
value={count}
visible={count !== 0}
testID={`${testID}.${id}.badge`}
style={styles.badge}
/>
) : null}
{requiresUserAttention ? (
<View
style={styles.dot}
testID={`${testID}.${id}.dot`}
/>
) : null}
</View>
</TouchableOpacity>
);
};
export default Tab;

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import {renderWithIntl} from '@test/intl-test-helper';
import Tab from './tab';
import Tabs from './tabs';
jest.mock('./tab', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(Tab).mockImplementation((props) => React.createElement('Tab', {...props}));
describe('Tabs', () => {
const baseProps: ComponentProps<typeof Tabs> = {
tabs: [
{
name: {
id: 'test.tab1',
defaultMessage: 'Test Tab 1',
},
id: 'tab1',
requiresUserAttention: false,
count: 2,
},
{
name: {
id: 'test.tab2',
defaultMessage: 'Test Tab 2',
},
id: 'tab2',
requiresUserAttention: true,
},
],
selectedTab: 'tab1',
onTabChange: jest.fn(),
testID: 'test.tabs',
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders all tabs correctly', () => {
const {getAllByTestId} = renderWithIntl(
<Tabs {...baseProps}/>,
);
const tabs = getAllByTestId('test.tabs');
const tab1 = tabs[0];
const tab2 = tabs[1];
expect(tab1.props.name).toEqual(baseProps.tabs[0].name);
expect(tab1.props.id).toBe('tab1');
expect(tab1.props.requiresUserAttention).toBe(false);
expect(tab1.props.isSelected).toBe(true);
expect(tab1.props.handleTabChange).toBe(baseProps.onTabChange);
expect(tab1.props.testID).toBe('test.tabs');
expect(tab1.props.count).toBe(2);
expect(tab2.props.name).toEqual(baseProps.tabs[1].name);
expect(tab2.props.id).toBe('tab2');
expect(tab2.props.requiresUserAttention).toBe(true);
expect(tab2.props.isSelected).toBe(false);
expect(tab2.props.handleTabChange).toBe(baseProps.onTabChange);
expect(tab2.props.testID).toBe('test.tabs');
expect(tab2.props.count).toBe(undefined);
});
it('uses `tabs` as testID when testID prop is not provided', () => {
const {getAllByTestId} = renderWithIntl(
<Tabs
{...baseProps}
testID={undefined}
/>,
);
const tabs = getAllByTestId('tabs');
expect(tabs.length).toBe(2);
});
});

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import Tab from './tab';
import type {TabDefinition} from '.';
type Props<T extends string> = {
tabs: Array<TabDefinition<T>>;
selectedTab: T;
onTabChange: (tabId: T) => void;
testID?: string;
};
const styles = StyleSheet.create({
menuContainer: {
alignItems: 'center',
flexGrow: 1,
flexDirection: 'row',
paddingLeft: 12,
marginVertical: 12,
overflow: 'hidden',
},
});
export default function Tabs<T extends string>({
tabs,
selectedTab,
onTabChange,
testID,
}: Props<T>) {
const tabsComponents = tabs.map(({name, id, requiresUserAttention, count}) => {
const isSelected = selectedTab === id;
return (
<Tab
key={id}
name={name}
id={id}
requiresUserAttention={requiresUserAttention}
handleTabChange={onTabChange}
isSelected={isSelected}
count={count}
testID={testID || 'tabs'}
/>
);
});
return (
<View style={styles.menuContainer}>
{tabsComponents}
</View>
);
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {MessageDescriptor} from 'react-intl';
export type TabDefinition<T extends string> = {
name: MessageDescriptor;
id: T;
requiresUserAttention?: boolean;
count?: number;
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHook, act} from '@testing-library/react-hooks';
import useTabs from './use_tabs';
import type {TabDefinition} from './types';
describe('hooks/useTabs', () => {
const defaultTabs: Array<TabDefinition<string>> = [
{id: 'tab1', name: {id: 'tab1.name', defaultMessage: 'Tab 1'}},
{id: 'tab2', name: {id: 'tab2.name', defaultMessage: 'Tab 2'}},
{id: 'tab3', name: {id: 'tab3.name', defaultMessage: 'Tab 3'}},
];
it('should initialize with the specified default tab', () => {
const {result} = renderHook(() => useTabs('tab1', defaultTabs));
const [selectedTab] = result.current;
expect(selectedTab).toBe('tab1');
});
it('should call change callback when tab changes', () => {
const mockCallback = jest.fn();
const {result} = renderHook(() => useTabs('tab1', defaultTabs, mockCallback));
const [, tabsProps] = result.current;
act(() => {
tabsProps.onTabChange('tab2');
});
expect(mockCallback).toHaveBeenCalledWith('tab2');
});
it('should render tabs with dots when specified', () => {
const tabsWithDot: Array<TabDefinition<string>> = [
{id: 'tab1', name: {id: 'tab1.name', defaultMessage: 'Tab 1'}, requiresUserAttention: true},
{id: 'tab2', name: {id: 'tab2.name', defaultMessage: 'Tab 2'}},
];
const {result} = renderHook(() => useTabs('tab1', tabsWithDot));
const [, tabsProps] = result.current;
expect(tabsProps.tabs[0].requiresUserAttention).toBe(true);
});
it('should use provided testID', () => {
const testID = 'test_tabs';
const {result} = renderHook(() => useTabs('tab1', defaultTabs, undefined, testID));
const [, tabsProps] = result.current;
expect(tabsProps.testID).toBe(testID);
});
it('should update selected tab when tab changes', () => {
const {result} = renderHook(() => useTabs('tab1', defaultTabs));
act(() => {
const [, tabsProps] = result.current;
tabsProps.onTabChange('tab2');
});
const [selectedTab] = result.current;
expect(selectedTab).toBe('tab2');
});
it('should use provided count', () => {
const tabsWithCount: Array<TabDefinition<string>> = [
{id: 'tab1', name: {id: 'tab1.name', defaultMessage: 'Tab 1'}, count: 1},
{id: 'tab2', name: {id: 'tab2.name', defaultMessage: 'Tab 2'}, count: 2},
];
const {result} = renderHook(() => useTabs('tab1', tabsWithCount));
const [, tabsProps] = result.current;
expect(tabsProps.tabs[0].count).toBe(1);
expect(tabsProps.tabs[1].count).toBe(2);
});
});

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useMemo, useState, type ComponentProps} from 'react';
import type Tabs from './tabs';
import type {TabDefinition} from './types';
function useTabs<T extends string>(defaultTab: T, tabs: Array<TabDefinition<T>>, changeCallback?: (value: T) => void, testID?: string) {
const [tab, setTab] = useState(defaultTab);
const handleTabChange = useCallback((value: T) => {
setTab(value);
changeCallback?.(value);
}, [changeCallback]);
const tabsProps = useMemo<ComponentProps<typeof Tabs>>(() => ({
tabs,
selectedTab: tab,
onTabChange: handleTabChange,
testID,
}), [tabs, tab, handleTabChange, testID]);
return [tab, tabsProps] as const;
}
export default useTabs;

View File

@@ -1,69 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, fireEvent, screen} from '@testing-library/react-native';
import React from 'react';
import {DRAFT_SCREEN_TAB_DRAFTS, DRAFT_SCREEN_TAB_SCHEDULED_POSTS} from '@constants/draft';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {DraftTabsHeader} from './draft_tabs_header';
describe('DraftTabsHeader', () => {
const baseProps: Parameters<typeof DraftTabsHeader>[0] = {
draftsCount: 5,
scheduledPostCount: 3,
selectedTab: 0,
onTabChange: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly with default props', async () => {
renderWithIntlAndTheme(<DraftTabsHeader {...baseProps}/>);
await act(async () => {
await TestHelper.wait(200); // Wait until the badge renders
});
expect(screen.getByTestId('draft_tab_container')).toBeTruthy();
expect(screen.getByTestId('draft_tab')).toBeTruthy();
expect(screen.getByTestId('scheduled_post_tab')).toBeTruthy();
// Check if badges are displayed with correct counts
expect(screen.getByTestId('draft_count_badge')).toBeTruthy();
expect(screen.getByText('5')).toBeTruthy();
expect(screen.getByTestId('scheduled_post_count_badge')).toBeTruthy();
expect(screen.getByText('3')).toBeTruthy();
});
it('calls onTabChange when draft tab is pressed', async () => {
renderWithIntlAndTheme(
<DraftTabsHeader
{...baseProps}
selectedTab={1}
/>,
);
await act(async () => {
await TestHelper.wait(200); // Wait until the badge renders
fireEvent.press(screen.getByTestId('draft_tab'));
});
expect(baseProps.onTabChange).toHaveBeenCalledWith(DRAFT_SCREEN_TAB_DRAFTS);
});
it('calls onTabChange when scheduled post tab is pressed', async () => {
renderWithIntlAndTheme(<DraftTabsHeader {...baseProps}/>);
await act(async () => {
await TestHelper.wait(200); // Wait until the badge renders
fireEvent.press(screen.getByTestId('scheduled_post_tab'));
});
expect(baseProps.onTabChange).toHaveBeenCalledWith(DRAFT_SCREEN_TAB_SCHEDULED_POSTS);
});
});

View File

@@ -1,174 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {TouchableOpacity, View} from 'react-native';
import Badge from '@components/badge';
import FormattedText from '@components/formatted_text';
import {DRAFT_SCREEN_TAB_SCHEDULED_POSTS, type DraftScreenTab} from '@constants/draft';
import {useTheme} from '@context/theme';
import {usePreventDoubleTap} from '@hooks/utils';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const DRAFT_TAB_INDEX = 0;
const SCHEDULED_POSTS_TAB_INDEX = 1;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
tabContainer: {
flexDirection: 'row',
alignItems: 'center',
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.08),
borderBottomWidth: 1,
},
menuContainer: {
alignItems: 'center',
flexGrow: 1,
flexDirection: 'row',
paddingLeft: 12,
marginVertical: 12,
flex: 1,
overflow: 'hidden',
},
menuItemContainer: {
paddingVertical: 8,
paddingHorizontal: 16,
flexDirection: 'row',
},
menuItemContainerSelected: {
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
borderRadius: 4,
flexDirection: 'row',
},
menuItem: {
color: changeOpacity(theme.centerChannelColor, 0.56),
alignSelf: 'center',
...typography('Body', 200, 'SemiBold'),
},
menuItemSelected: {
color: theme.buttonBg,
},
badgeStyles: {
position: 'relative',
color: changeOpacity(theme.centerChannelColor, 0.75),
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
alignSelf: 'center',
left: 4,
top: 1,
borderWidth: 0,
paddingTop: 2,
},
activeBadgeStyles: {
color: theme.buttonBg,
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
},
};
});
type Props = {
draftsCount: number;
scheduledPostCount: number;
selectedTab: DraftScreenTab;
onTabChange: (tab: DraftScreenTab) => void;
}
export function DraftTabsHeader({draftsCount, scheduledPostCount, selectedTab, onTabChange}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
const viewingDrafts = selectedTab === DRAFT_TAB_INDEX;
const draftCountBadge = () => {
const style = [styles.badgeStyles, selectedTab === DRAFT_TAB_INDEX ? styles.activeBadgeStyles : null];
return (
<Badge
value={draftsCount}
visible={draftsCount !== 0}
style={style}
testID='draft_count_badge'
/>
);
};
const scheduledPostCountBadge = () => {
const style = [styles.badgeStyles, selectedTab === DRAFT_SCREEN_TAB_SCHEDULED_POSTS ? styles.activeBadgeStyles : null];
return (
<Badge
value={scheduledPostCount}
visible={scheduledPostCount !== 0}
style={style}
testID='scheduled_post_count_badge'
/>
);
};
const {draftsContanerStyle, draftsTabStyle, scheduledContainerStyle, scheduledTabStyle} = useMemo(() => {
return {
draftsContanerStyle: [
styles.menuItemContainer,
viewingDrafts ? styles.menuItemContainerSelected : undefined,
],
draftsTabStyle: [
styles.menuItem,
viewingDrafts ? styles.menuItemSelected : undefined,
],
scheduledContainerStyle: [
styles.menuItemContainer,
viewingDrafts ? undefined : styles.menuItemContainerSelected,
],
scheduledTabStyle: [
styles.menuItem,
viewingDrafts ? undefined : styles.menuItemSelected,
],
};
}, [styles, viewingDrafts]);
const onDraftTabPress = usePreventDoubleTap(useCallback(() => {
onTabChange(DRAFT_TAB_INDEX);
}, [onTabChange]));
const onScheduledPostTabPress = usePreventDoubleTap(useCallback(() => {
onTabChange(SCHEDULED_POSTS_TAB_INDEX);
}, [onTabChange]));
return (
<View
style={styles.tabContainer}
testID='draft_tab_container'
>
<View style={styles.menuContainer}>
<TouchableOpacity
onPress={onDraftTabPress}
testID='draft_tab'
accessibilityState={{selected: selectedTab === DRAFT_TAB_INDEX}}
>
<View style={draftsContanerStyle}>
<FormattedText
id='drafts_tab.title.drafts'
defaultMessage='Drafts'
style={draftsTabStyle}
/>
{draftCountBadge()}
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={onScheduledPostTabPress}
testID='scheduled_post_tab'
accessibilityState={{selected: selectedTab === DRAFT_SCREEN_TAB_SCHEDULED_POSTS}}
>
<View style={scheduledContainerStyle}>
<FormattedText
id='drafts_tab.title.scheduled'
defaultMessage='Scheduled'
style={scheduledTabStyle}
/>
{scheduledPostCountBadge()}
</View>
</TouchableOpacity>
</View>
</View>
);
}

View File

@@ -59,8 +59,8 @@ describe('TabbedContents', () => {
await TestHelper.wait(300); // Wait until the badge renders
});
const scheduledTab = screen.getByTestId('scheduled_post_tab');
const draftTab = screen.getByTestId('draft_tab');
const scheduledTab = screen.getByTestId('tabs.scheduled_posts.button');
const draftTab = screen.getByTestId('tabs.drafts.button');
// Check that the drafts tab is selected
expect(scheduledTab.props.accessibilityState).toEqual({selected: false});
@@ -86,8 +86,8 @@ describe('TabbedContents', () => {
await TestHelper.wait(300);
});
const scheduledTab = screen.getByTestId('scheduled_post_tab');
const draftTab = screen.getByTestId('draft_tab');
const scheduledTab = screen.getByTestId('tabs.scheduled_posts.button');
const draftTab = screen.getByTestId('tabs.drafts.button');
// ✅ Check accessibilityState
expect(scheduledTab.props.accessibilityState).toEqual({selected: true});
@@ -105,19 +105,19 @@ describe('TabbedContents', () => {
await TestHelper.wait(300); // Wait until the badge renders
});
expect(screen.getByTestId('draft_tab')).toBeTruthy();
expect(screen.getByTestId('tabs.drafts.button')).toBeTruthy();
expect(screen.getByTestId('drafts-content')).toBeTruthy();
await act(async () => {
fireEvent.press(screen.getByTestId('scheduled_post_tab'));
fireEvent.press(screen.getByTestId('tabs.scheduled_posts.button'));
await TestHelper.wait(0);
});
expect(screen.getByTestId('scheduled-posts-content')).toBeTruthy();
await act(async () => {
fireEvent.press(screen.getByTestId('draft_tab'));
fireEvent.press(screen.getByTestId('tabs.drafts.button'));
await TestHelper.wait(0);
});
@@ -149,7 +149,7 @@ describe('TabbedContents', () => {
// Click on scheduled posts tab to make that content visible
await act(async () => {
fireEvent.press(screen.getByTestId('scheduled_post_tab'));
fireEvent.press(screen.getByTestId('tabs.scheduled_posts.button'));
// Add a small delay to allow animations to complete
await TestHelper.wait(0);

View File

@@ -3,11 +3,13 @@
import React, {type ReactNode, useState, useMemo, useCallback} from 'react';
import {Freeze} from 'react-freeze';
import {defineMessage} from 'react-intl';
import {StyleSheet, View, type LayoutChangeEvent} from 'react-native';
import Animated, {runOnJS, useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {DRAFT_SCREEN_TAB_DRAFTS, DRAFT_SCREEN_TAB_SCHEDULED_POSTS, type DraftScreenTab} from '@constants/draft';
import {DraftTabsHeader} from '@screens/global_drafts/components/tabbed_contents/draft_tabs_header';
import useTabs from '@hooks/use_tabs';
import Tabs from '@hooks/use_tabs/tabs';
const duration = 250;
@@ -38,7 +40,25 @@ const getStyleSheet = (width: number) => {
};
export default function TabbedContents({draftsCount, scheduledPostCount, initialTab, drafts, scheduledPosts}: Props) {
const [selectedTab, setSelectedTab] = useState(initialTab);
const onSelectTab = useCallback((tab: DraftScreenTab) => {
if (tab === DRAFT_SCREEN_TAB_DRAFTS) {
setFreezeDraft(false);
} else {
setFreezeScheduledPosts(false);
}
}, []);
const [selectedTab, tabProps] = useTabs(initialTab, [
{
id: DRAFT_SCREEN_TAB_DRAFTS,
name: defineMessage({id: 'drafts_tab.title.drafts', defaultMessage: 'Drafts'}),
count: draftsCount,
},
{
id: DRAFT_SCREEN_TAB_SCHEDULED_POSTS,
name: defineMessage({id: 'drafts_tab.title.scheduled', defaultMessage: 'Scheduled'}),
count: scheduledPostCount,
},
], onSelectTab);
const [freezeDraft, setFreezeDraft] = useState(initialTab !== DRAFT_SCREEN_TAB_DRAFTS);
const [freezeScheduledPosts, setFreezeScheduledPosts] = useState(initialTab !== DRAFT_SCREEN_TAB_SCHEDULED_POSTS);
const [width, setWidth] = useState(0);
@@ -69,28 +89,15 @@ export default function TabbedContents({draftsCount, scheduledPostCount, initial
zIndex: 0,
}));
const onSelectTab = useCallback((tab: DraftScreenTab) => {
setSelectedTab(tab);
if (tab === DRAFT_SCREEN_TAB_DRAFTS) {
setFreezeDraft(false);
} else {
setFreezeScheduledPosts(false);
}
}, []);
return (
<View
style={styles.tabContainer}
onLayout={onLayout}
testID='tabbed_contents'
>
<DraftTabsHeader
draftsCount={draftsCount}
scheduledPostCount={scheduledPostCount}
selectedTab={selectedTab}
onTabChange={onSelectTab}
/>
<View>
<Tabs {...tabProps}/>
</View>
<Animated.View style={[styles.tabContentContainer]}>
<Animated.View
style={[firstTabStyle, styles.tabContent]}

View File

@@ -45,9 +45,8 @@ describe('screens/global_drafts', () => {
await TestHelper.wait(200); // Wait until the badge renders
});
expect(getByTestId('draft_tab_container')).toBeVisible();
expect(getByTestId('draft_tab')).toBeVisible();
expect(getByTestId('scheduled_post_tab')).toBeVisible();
expect(getByTestId('tabs.drafts.button')).toBeVisible();
expect(getByTestId('tabs.scheduled_posts.button')).toBeVisible();
});
it('should switch between tabs', async () => {
@@ -62,8 +61,8 @@ describe('screens/global_drafts', () => {
await TestHelper.wait(200); // Wait until the badge renders
});
const draftTab = getByTestId('draft_tab');
const scheduledTab = getByTestId('scheduled_post_tab');
const draftTab = getByTestId('tabs.drafts.button');
const scheduledTab = getByTestId('tabs.scheduled_posts.button');
const tabbedContents = getByTestId('tabbed_contents');
await act(async () => {

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {setGlobalThreadsTab} from '@actions/local/systems';
import NavigationHeader from '@components/navigation_header';
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import useTabs from '@hooks/use_tabs';
import {renderWithEverything} from '@test/intl-test-helper';
import GlobalThreads from './global_threads';
import Header from './threads_list/header';
import type {Database} from '@nozbe/watermelondb';
jest.mock('@hooks/device', () => ({
useIsTablet: jest.fn().mockReturnValue(false),
}));
jest.mock('@hooks/header', () => ({
useDefaultHeaderHeight: jest.fn().mockReturnValue(50),
}));
jest.mock('@hooks/team_switch', () => ({
useTeamSwitch: jest.fn().mockReturnValue(false),
}));
jest.mock('@actions/local/systems', () => ({
setGlobalThreadsTab: jest.fn(),
}));
jest.mock('@components/navigation_header', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(NavigationHeader).mockImplementation((props) => React.createElement('NavigationHeader', props));
jest.mock('./threads_list/header', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(Header).mockImplementation((props) => React.createElement('Header', props));
jest.mock('@hooks/use_tabs', () => ({
__esModule: true,
default: jest.fn(jest.requireActual('@hooks/use_tabs').default),
}));
describe('GlobalThreads', () => {
const serverUrl = 'https://example.com';
let database: Database;
const baseProps = {
componentId: Screens.GLOBAL_THREADS,
globalThreadsTab: 'all' as const,
hasUnreads: false,
teamId: 'team-id',
};
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const result = DatabaseManager.serverDatabases[serverUrl]!;
database = result.database;
});
afterEach(async () => {
await DatabaseManager.deleteServerDatabase(serverUrl);
});
it('renders correctly with default props', () => {
renderWithEverything(<GlobalThreads {...baseProps}/>, {
database,
});
expect(useTabs).toHaveBeenCalledWith(
expect.any(String),
expect.arrayContaining([
expect.objectContaining({id: 'unreads', name: expect.objectContaining({defaultMessage: 'Unreads'})}),
expect.objectContaining({id: 'all', name: expect.objectContaining({defaultMessage: 'All your threads'})}),
]),
expect.any(Function),
expect.any(String),
);
});
it('shows dot indicator when hasUnreads is true', () => {
const {rerender} = renderWithEverything(
<GlobalThreads
{...baseProps}
hasUnreads={true}
/>,
{
database,
},
);
// Verify the unreads tab has a dot indicator
expect(useTabs).toHaveBeenCalledWith(
expect.any(String),
expect.arrayContaining([
expect.objectContaining({id: 'unreads', requiresUserAttention: true}),
expect.objectContaining({id: 'all', requiresUserAttention: false}),
]),
expect.any(Function),
expect.any(String),
);
jest.mocked(useTabs).mockClear();
rerender(
<GlobalThreads
{...baseProps}
hasUnreads={false}
/>,
);
expect(useTabs).toHaveBeenCalledWith(
expect.any(String),
expect.arrayContaining([
expect.objectContaining({id: 'unreads', requiresUserAttention: false}),
expect.objectContaining({id: 'all', requiresUserAttention: false}),
]),
expect.any(Function),
expect.any(String),
);
});
it('passes teamId to Header component', () => {
const {getByTestId} = renderWithEverything(<GlobalThreads {...baseProps}/>, {
database,
});
const header = getByTestId('global_threads.threads_list.header');
expect(header.props.teamId).toBe('team-id');
});
it('saves tab selection on unmount', () => {
const {unmount} = renderWithEverything(<GlobalThreads {...baseProps}/>, {
database,
});
unmount();
expect(setGlobalThreadsTab).toHaveBeenCalledWith(expect.any(String), 'all');
});
});

View File

@@ -1,9 +1,9 @@
// 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 {useIntl} from 'react-intl';
import {Keyboard, StyleSheet, View} from 'react-native';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {defineMessage, useIntl} from 'react-intl';
import {FlatList, Keyboard, StyleSheet, View} from 'react-native';
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import {setGlobalThreadsTab} from '@actions/local/systems';
@@ -16,16 +16,21 @@ import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
import {useTeamSwitch} from '@hooks/team_switch';
import useTabs, {type TabDefinition} from '@hooks/use_tabs';
import SecurityManager from '@managers/security_manager';
import {popTopScreen} from '@screens/navigation';
import ThreadsList from './threads_list';
import Header from './threads_list/header';
import type ThreadModel from '@typings/database/models/servers/thread';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
componentId?: AvailableScreens;
globalThreadsTab: GlobalThreadsTab;
hasUnreads: boolean;
teamId: string;
};
const edges: Edge[] = ['left', 'right'];
@@ -36,15 +41,41 @@ const styles = StyleSheet.create({
},
});
const GlobalThreads = ({componentId, globalThreadsTab}: Props) => {
const testID = 'global_threads.threads_list';
const GlobalThreads = ({componentId, globalThreadsTab, hasUnreads, teamId}: Props) => {
const serverUrl = useServerUrl();
const intl = useIntl();
const switchingTeam = useTeamSwitch();
const isTablet = useIsTablet();
const flatListRef = useRef<FlatList<ThreadModel>>(null);
const defaultHeight = useDefaultHeaderHeight();
const [tab, setTab] = useState<GlobalThreadsTab>(globalThreadsTab);
const tabs = useMemo<Array<TabDefinition<GlobalThreadsTab>>>(() => [
{
name: defineMessage({
id: 'global_threads.allThreads',
defaultMessage: 'All your threads',
}),
id: 'all',
requiresUserAttention: false,
},
{
name: defineMessage({
id: 'global_threads.unreads',
defaultMessage: 'Unreads',
}),
id: 'unreads',
requiresUserAttention: hasUnreads,
},
], [hasUnreads]);
const tabOnChange = useCallback(() => {
flatListRef.current?.scrollToOffset({offset: 0});
}, []);
const [tab, tabsProps] = useTabs<GlobalThreadsTab>(globalThreadsTab, tabs, tabOnChange, 'global_threads.threads_list.header');
const mounted = useRef(false);
const containerStyle = useMemo(() => {
@@ -104,10 +135,16 @@ const GlobalThreads = ({componentId, globalThreadsTab}: Props) => {
</View>
{!switchingTeam &&
<View style={containerStyle}>
<Header
teamId={teamId}
testID={`${testID}.header`}
hasUnreads={hasUnreads}
tabsProps={tabsProps}
/>
<ThreadsList
setTab={setTab}
tab={tab}
testID={'global_threads.threads_list'}
testID={testID}
flatListRef={flatListRef}
/>
</View>
}

View File

@@ -0,0 +1,158 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database} from '@nozbe/watermelondb';
import React from 'react';
import {processReceivedThreads} from '@actions/local/thread';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {renderWithEverything, act} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import GlobalThreads from './global_threads';
import EnhancedGlobalThreads from './index';
import type ServerDataOperator from '@database/operator/server_data_operator';
jest.mock('./global_threads', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(GlobalThreads).mockImplementation((props) => React.createElement('GlobalThreads', {...props, testID: 'global-threads'}));
describe('GlobalThreads enhanced component', () => {
const serverUrl = 'server-1';
const teamId = 'team1';
let database: Database;
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}],
prepareRecordsOnly: false,
});
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('should correctly show hasUnreads when there are unread threads', async () => {
const channelId = 'channel1';
const threadId = 'thread1';
await operator.handleChannel({
channels: [TestHelper.fakeChannel({id: channelId, team_id: teamId})],
prepareRecordsOnly: false,
});
await processReceivedThreads(serverUrl, [TestHelper.fakeThread({
id: threadId,
reply_count: 1,
unread_replies: 0,
post: TestHelper.fakePost({id: threadId, channel_id: channelId}),
is_following: true,
})], teamId, false);
const {getByTestId} = renderWithEverything(<EnhancedGlobalThreads/>, {database});
const globalThreads = getByTestId('global-threads');
expect(globalThreads.props.hasUnreads).toBe(false);
await act(async () => {
await processReceivedThreads(serverUrl, [TestHelper.fakeThread({
id: threadId,
reply_count: 1,
unread_replies: 1,
post: TestHelper.fakePost({id: threadId, channel_id: channelId}),
is_following: true,
})], teamId, false);
});
expect(globalThreads.props.hasUnreads).toBe(true);
});
it('should select the threads for unreads based on the current team', async () => {
const channelId1 = 'channel1';
const threadId1 = 'thread1';
const channelId2 = 'channel2';
const threadId2 = 'thread2';
const teamId2 = 'team2';
await operator.handleChannel({
channels: [TestHelper.fakeChannel({id: channelId1, team_id: teamId})],
prepareRecordsOnly: false,
});
await processReceivedThreads(serverUrl, [TestHelper.fakeThread({
id: threadId1,
reply_count: 1,
unread_replies: 0,
post: TestHelper.fakePost({id: threadId1, channel_id: channelId1}),
is_following: true,
})], teamId, false);
await operator.handleChannel({
channels: [TestHelper.fakeChannel({id: channelId2, team_id: teamId2})],
prepareRecordsOnly: false,
});
await processReceivedThreads(serverUrl, [TestHelper.fakeThread({
id: threadId2,
reply_count: 1,
unread_replies: 1,
post: TestHelper.fakePost({id: threadId2, channel_id: channelId2}),
is_following: true,
})], teamId2, false);
const {getByTestId} = renderWithEverything(<EnhancedGlobalThreads/>, {database});
const globalThreads = getByTestId('global-threads');
expect(globalThreads.props.hasUnreads).toBe(false);
await act(async () => {
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId2}],
prepareRecordsOnly: false,
});
});
expect(globalThreads.props.hasUnreads).toBe(true);
});
it('should update teamId when current team changes', async () => {
const {getByTestId} = renderWithEverything(<EnhancedGlobalThreads/>, {database});
const globalThreads = getByTestId('global-threads');
expect(globalThreads.props.teamId).toBe(teamId);
const newTeamId = 'team2';
await act(async () => {
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: newTeamId}],
prepareRecordsOnly: false,
});
});
expect(globalThreads.props.teamId).toBe(newTeamId);
});
it('should observe global threads tab changes', async () => {
const {getByTestId} = renderWithEverything(<EnhancedGlobalThreads/>, {database});
const globalThreads = getByTestId('global-threads');
// Default tab should be 'all'
expect(globalThreads.props.globalThreadsTab).toBe('all');
await act(async () => {
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.GLOBAL_THREADS_TAB, value: 'unreads'}],
prepareRecordsOnly: false,
});
});
expect(globalThreads.props.globalThreadsTab).toBe('unreads');
});
});

View File

@@ -2,15 +2,26 @@
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {distinctUntilChanged, switchMap} from '@nozbe/watermelondb/utils/rx';
import {of as of$} from 'rxjs';
import {observeGlobalThreadsTab} from '@queries/servers/system';
import {observeCurrentTeamId, observeGlobalThreadsTab} from '@queries/servers/system';
import {queryThreadsInTeam} from '@queries/servers/thread';
import GlobalThreads from './global_threads';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const teamId = observeCurrentTeamId(database);
const unreadsCount = teamId.pipe(switchMap((id) => queryThreadsInTeam(database, id, true, true, true).observeCount(false)));
const hasUnreads = unreadsCount.pipe(
switchMap((count) => of$(count > 0)),
distinctUntilChanged(),
);
return {
teamId,
hasUnreads,
globalThreadsTab: observeGlobalThreadsTab(database),
};
});

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import {renderWithIntl} from '@test/intl-test-helper';
import Header from './index';
describe('components/global_threads/threads_list/header', () => {
const baseProps: ComponentProps<typeof Header> = {
tabsProps: {
tabs: [{
id: 'tab1',
name: {id: 'tab1.name', defaultMessage: 'Tab 1'},
requiresUserAttention: true,
}],
selectedTab: 'tab1',
onTabChange: jest.fn(),
testID: 'testID',
},
teamId: 'teamId',
testID: 'testID',
hasUnreads: true,
};
it('should render tab component', () => {
const props = {
...baseProps,
};
const {getByTestId} = renderWithIntl(
<Header {...props}/>,
);
// Verify the tab component is rendered
expect(getByTestId('testID.tab1.button')).toBeTruthy();
});
});

View File

@@ -1,24 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import React, {useCallback, useMemo, type ComponentProps} from 'react';
import {useIntl} from 'react-intl';
import {Alert, TouchableOpacity, View} from 'react-native';
import {updateTeamThreadsAsRead} from '@actions/remote/thread';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {preventDoubleTap} from '@utils/tap';
import Tabs from '@hooks/use_tabs/tabs';
import {usePreventDoubleTap} from '@hooks/utils';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
export type Props = {
setTab: (tab: GlobalThreadsTab) => void;
tab: GlobalThreadsTab;
tabsProps: ComponentProps<typeof Tabs>;
teamId: string;
testID: string;
unreadsCount: number;
hasUnreads: boolean;
};
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
@@ -29,41 +27,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
borderBottomWidth: 1,
flexDirection: 'row',
},
menuContainer: {
alignItems: 'center',
flexGrow: 1,
flexDirection: 'row',
paddingLeft: 12,
marginVertical: 12,
flex: 1,
overflow: 'hidden',
},
menuItemContainer: {
paddingVertical: 8,
paddingHorizontal: 16,
},
menuItemContainerSelected: {
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
borderRadius: 4,
},
menuItem: {
color: changeOpacity(theme.centerChannelColor, 0.56),
alignSelf: 'center',
...typography('Body', 200, 'SemiBold'),
},
menuItemSelected: {
color: theme.buttonBg,
},
unreadsDot: {
position: 'absolute',
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.sidebarTextActiveBorder,
right: -6,
top: 4,
},
markAllReadIconContainer: {
paddingHorizontal: 20,
},
@@ -78,16 +41,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
};
});
const Header = ({setTab, tab, teamId, testID, unreadsCount}: Props) => {
const Header = ({tabsProps, teamId, testID, hasUnreads}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const intl = useIntl();
const serverUrl = useServerUrl();
const hasUnreads = unreadsCount > 0;
const viewingUnreads = tab === 'unreads';
const handleMarkAllAsRead = useCallback(preventDoubleTap(() => {
const handleMarkAllAsRead = usePreventDoubleTap(useCallback(() => {
Alert.alert(
intl.formatMessage({
id: 'global_threads.markAllRead.title',
@@ -114,31 +74,7 @@ const Header = ({setTab, tab, teamId, testID, unreadsCount}: Props) => {
},
}],
);
}), [intl, serverUrl, teamId]);
const handleViewAllThreads = useCallback(preventDoubleTap(() => setTab('all')), []);
const handleViewUnreadThreads = useCallback(preventDoubleTap(() => setTab('unreads')), []);
const {allThreadsContainerStyle, allThreadsStyle, unreadsContainerStyle, unreadsStyle} = useMemo(() => {
return {
allThreadsContainerStyle: [
styles.menuItemContainer,
viewingUnreads ? undefined : styles.menuItemContainerSelected,
],
allThreadsStyle: [
styles.menuItem,
viewingUnreads ? undefined : styles.menuItemSelected,
],
unreadsContainerStyle: [
styles.menuItemContainer,
viewingUnreads ? styles.menuItemContainerSelected : undefined,
],
unreadsStyle: [
styles.menuItem,
viewingUnreads ? styles.menuItemSelected : undefined,
],
};
}, [styles, viewingUnreads]);
}, [intl, serverUrl, teamId]));
const markAllStyle = useMemo(() => [
styles.markAllReadIcon,
@@ -147,40 +83,7 @@ const Header = ({setTab, tab, teamId, testID, unreadsCount}: Props) => {
return (
<View style={styles.container}>
<View style={styles.menuContainer}>
<TouchableOpacity
onPress={handleViewAllThreads}
testID={`${testID}.all_threads.button`}
>
<View style={allThreadsContainerStyle}>
<FormattedText
id='global_threads.allThreads'
defaultMessage='All your threads'
style={allThreadsStyle}
/>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={handleViewUnreadThreads}
testID={`${testID}.unread_threads.button`}
>
<View style={unreadsContainerStyle}>
<View>
<FormattedText
id='global_threads.unreads'
defaultMessage='Unreads'
style={unreadsStyle}
/>
{hasUnreads ? (
<View
style={styles.unreadsDot}
testID={`${testID}.unreads_dot.badge`}
/>
) : null}
</View>
</View>
</TouchableOpacity>
</View>
<Tabs {...tabsProps}/>
<View style={styles.markAllReadIconContainer}>
<TouchableOpacity
disabled={!hasUnreads}

View File

@@ -27,7 +27,6 @@ const enhanced = withObservables(['tab', 'teamId'], ({database, tab, teamId}: Pr
const teamThreadsSyncObserver = queryTeamThreadsSync(database, teamId).observeWithColumns(['earliest']);
return {
unreadsCount: queryThreadsInTeam(database, teamId, true, true, true).observeCount(false),
teammateNameDisplay: observeTeammateNameDisplay(database),
threads: teamThreadsSyncObserver.pipe(
switchMap((teamThreadsSync) => {

View File

@@ -12,19 +12,17 @@ import {useTheme} from '@context/theme';
import EmptyState from './empty_state';
import EndOfList from './end_of_list';
import Header from './header';
import Thread from './thread';
import type ThreadModel from '@typings/database/models/servers/thread';
type Props = {
setTab: (tab: GlobalThreadsTab) => void;
tab: GlobalThreadsTab;
teamId: string;
teammateNameDisplay: string;
testID: string;
threads: ThreadModel[];
unreadsCount: number;
flatListRef: React.RefObject<FlatList<ThreadModel>>;
};
const styles = StyleSheet.create({
@@ -46,18 +44,16 @@ const styles = StyleSheet.create({
});
const ThreadsList = ({
setTab,
tab,
teamId,
teammateNameDisplay,
testID,
threads,
unreadsCount,
flatListRef,
}: Props) => {
const serverUrl = useServerUrl();
const theme = useTheme();
const flatListRef = useRef<FlatList<ThreadModel>>(null);
const hasFetchedOnce = useRef(false);
const [isLoading, setIsLoading] = useState(false);
const [endReached, setEndReached] = useState(false);
@@ -97,8 +93,10 @@ const ThreadsList = ({
);
}, [isLoading, theme, tab]);
const hasThreads = threads.length > 0;
const listFooterComponent = useMemo(() => {
if (tab === 'unreads' || !threads.length) {
if (tab === 'unreads' || !hasThreads) {
return null;
}
@@ -116,12 +114,7 @@ const ThreadsList = ({
}
return null;
}, [isLoading, tab, theme, endReached]);
const handleTabChange = useCallback((value: GlobalThreadsTab) => {
setTab(value);
flatListRef.current?.scrollToOffset({animated: true, offset: 0});
}, [setTab]);
}, [tab, hasThreads, endReached, isLoading, theme.buttonBg]);
const handleRefresh = useCallback(() => {
setRefreshing(true);
@@ -157,29 +150,20 @@ const ThreadsList = ({
), [teammateNameDisplay, testID]);
return (
<>
<Header
setTab={handleTabChange}
tab={tab}
teamId={teamId}
testID={`${testID}.header`}
unreadsCount={unreadsCount}
/>
<FlatList
ListEmptyComponent={listEmptyComponent}
ListFooterComponent={listFooterComponent}
contentContainerStyle={threads.length ? styles.messagesContainer : styles.empty}
data={threads}
maxToRenderPerBatch={10}
onEndReached={handleEndReached}
onRefresh={handleRefresh}
ref={flatListRef}
refreshing={isRefreshing}
removeClippedSubviews={true}
renderItem={renderItem}
testID={`${testID}.flat_list`}
/>
</>
<FlatList
ListEmptyComponent={listEmptyComponent}
ListFooterComponent={listFooterComponent}
contentContainerStyle={threads.length ? styles.messagesContainer : styles.empty}
data={threads}
maxToRenderPerBatch={10}
onEndReached={handleEndReached}
onRefresh={handleRefresh}
ref={flatListRef}
refreshing={isRefreshing}
removeClippedSubviews={true}
renderItem={renderItem}
testID={`${testID}.flat_list`}
/>
);
};

View File

@@ -3,7 +3,7 @@
import React from 'react';
import {renderWithIntlAndTheme, fireEvent} from '@test/intl-test-helper';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import {FileFilters} from '@utils/file';
import {TabTypes} from '@utils/search';
@@ -15,7 +15,6 @@ import type TeamModel from '@typings/database/models/servers/team';
jest.mock('@react-native-camera-roll/camera-roll', () => ({}));
describe('Header', () => {
const onTabSelect = jest.fn();
const onFilterChanged = jest.fn();
const setTeamId = jest.fn();
@@ -24,71 +23,28 @@ describe('Header', () => {
{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);
});
const baseTabsProps = {
tabs: [{
id: 'tab1',
name: {id: 'tab1.name', defaultMessage: 'Tab 1'},
hasDot: true,
}],
selectedTab: 'tab1',
onTabChange: jest.fn(),
testID: 'testID',
};
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}
tabsProps={baseTabsProps}
/>,
);
@@ -100,12 +56,12 @@ describe('Header', () => {
<Header
teamId='team1'
setTeamId={setTeamId}
onTabSelect={onTabSelect}
onFilterChanged={onFilterChanged}
selectedTab={TabTypes.MESSAGES}
selectedFilter={FileFilters.ALL}
teams={teams}
crossTeamSearchEnabled={false}
tabsProps={baseTabsProps}
/>,
);
@@ -120,12 +76,12 @@ describe('Header', () => {
<Header
teamId='team1'
setTeamId={setTeamId}
onTabSelect={onTabSelect}
onFilterChanged={onFilterChanged}
selectedTab={selectedTab}
selectedFilter={FileFilters.ALL}
teams={teams}
crossTeamSearchEnabled={false}
tabsProps={baseTabsProps}
/>,
);
@@ -135,4 +91,21 @@ describe('Header', () => {
expect(queryByTestId('search.filters.file_type_icon')).toBeFalsy();
}
});
it('should render the provided tabsComponent', () => {
const {getByTestId} = renderWithIntlAndTheme(
<Header
teamId='team1'
setTeamId={setTeamId}
onFilterChanged={onFilterChanged}
selectedTab={TabTypes.MESSAGES}
selectedFilter={FileFilters.ALL}
teams={teams}
crossTeamSearchEnabled={false}
tabsProps={baseTabsProps}
/>,
);
expect(getByTestId('testID.tab1.button')).toBeTruthy();
});
});

View File

@@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import React, {useCallback, useMemo, type ComponentProps} from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
@@ -9,6 +9,7 @@ import CompassIcon from '@components/compass_icon';
import Filter, {DIVIDERS_HEIGHT, FILTER_ITEM_HEIGHT, NUMBER_FILTER_ITEMS} from '@components/files/file_filter';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import Tabs from '@hooks/use_tabs/tabs';
import {TITLE_SEPARATOR_MARGIN, TITLE_SEPARATOR_MARGIN_TABLET, TITLE_HEIGHT} from '@screens/bottom_sheet/content';
import TeamPicker from '@screens/home/search/team_picker';
import {bottomSheet} from '@screens/navigation';
@@ -17,12 +18,9 @@ import {bottomSheetSnapPoint} from '@utils/helpers';
import {TabTypes, type TabType} from '@utils/search';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import SelectButton from './header_button';
import type TeamModel from '@typings/database/models/servers/team';
type Props = {
onTabSelect: (tab: TabType) => void;
onFilterChanged: (filter: FileFilter) => void;
selectedTab: TabType;
selectedFilter: FileFilter;
@@ -30,6 +28,7 @@ type Props = {
teamId: string;
teams: TeamModel[];
crossTeamSearchEnabled: boolean;
tabsProps: ComponentProps<typeof Tabs>;
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
@@ -60,7 +59,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
justifyContent: 'flex-end',
},
filterContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
@@ -72,33 +70,23 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
const Header = ({
teamId,
setTeamId,
onTabSelect,
onFilterChanged,
selectedTab,
selectedFilter,
teams,
crossTeamSearchEnabled,
tabsProps,
}: Props) => {
const theme = useTheme();
const styles = getStyleFromTheme(theme);
const intl = useIntl();
const isTablet = useIsTablet();
const messagesText = intl.formatMessage({id: 'screen.search.header.messages', defaultMessage: 'Messages'});
const filesText = intl.formatMessage({id: 'screen.search.header.files', defaultMessage: 'Files'});
const title = intl.formatMessage({id: 'screen.search.results.filter.title', defaultMessage: 'Filter by file type'});
const showFilterIcon = selectedTab === TabTypes.FILES;
const hasFilters = selectedFilter !== FileFilters.ALL;
const handleMessagesPress = useCallback(() => {
onTabSelect(TabTypes.MESSAGES);
}, [onTabSelect]);
const handleFilesPress = useCallback(() => {
onTabSelect(TabTypes.FILES);
}, [onTabSelect]);
const snapPoints = useMemo(() => {
return [
1,
@@ -131,48 +119,40 @@ const Header = ({
return (
<View style={styles.container}>
<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>
<Tabs {...tabsProps}/>
{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>
<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>
)}
<View style={styles.teamPickerContainer}>
{teams.length > 1 && (
{teams.length > 1 && (
<View style={styles.teamPickerContainer}>
<TeamPicker
setTeamId={setTeamId}
teamId={teamId}
teams={teams}
crossTeamSearchEnabled={crossTeamSearchEnabled}
/>
)}
</View>
</View>
)}
</View>
</View>
);

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Button} from '@rneui/base';
import React from 'react';
import {Text} from 'react-native';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
button: {
alignItems: 'center',
borderRadius: 4,
backgroundColor: 'transparent',
padding: 0,
},
text: {
paddingHorizontal: 12,
paddingVertical: 8,
...typography('Body', 200, 'SemiBold'),
},
selectedButton: {
backgroundColor: changeOpacity(theme.buttonBg, 0.1),
},
selectedText: {
color: theme.buttonBg,
},
unselectedText: {
color: changeOpacity(theme.centerChannelColor, 0.56),
},
};
});
type ButtonProps = {
onPress: () => void;
selected: boolean;
text: string;
}
const SelectButton = ({selected, onPress, text}: ButtonProps) => {
const theme = useTheme();
const styles = getStyleFromTheme(theme);
return (
<Button
buttonStyle={[styles.button, selected && styles.selectedButton]}
onPress={onPress}
>
<Text
style={[styles.text, selected ? styles.selectedText : styles.unselectedText]}
>
{text}
</Text >
</Button>
);
};
export default SelectButton;

View File

@@ -6,6 +6,7 @@ import React from 'react';
import {addSearchToTeamSearchHistory} from '@actions/local/team';
import {searchPosts, searchFiles} from '@actions/remote/search';
import useTabs from '@hooks/use_tabs';
import {bottomSheet} from '@screens/navigation';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
@@ -49,6 +50,11 @@ jest.mock('@screens/navigation', () => ({
bottomSheet: jest.fn(),
}));
jest.mock('@hooks/use_tabs', () => ({
__esModule: true,
default: jest.fn(jest.requireActual('@hooks/use_tabs').default),
}));
describe('SearchScreen', () => {
const baseProps = {
teamId: 'team1',
@@ -181,4 +187,21 @@ describe('SearchScreen', () => {
);
});
});
it('initializes with correct tabs configuration', () => {
renderWithEverything(
<SearchScreen {...baseProps}/>,
{database},
);
expect(useTabs).toHaveBeenCalledWith(
'MESSAGES',
[
expect.objectContaining({id: 'MESSAGES', name: expect.objectContaining({defaultMessage: 'Messages'})}),
expect.objectContaining({id: 'FILES', name: expect.objectContaining({defaultMessage: 'Files'})}),
],
undefined,
expect.any(String),
);
});
});

View File

@@ -5,7 +5,7 @@ import {useHardwareKeyboardEvents} from '@mattermost/hardware-keyboard';
import {useIsFocused, useNavigation} from '@react-navigation/native';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {Freeze} from 'react-freeze';
import {useIntl} from 'react-intl';
import {defineMessage, useIntl} from 'react-intl';
import {FlatList, type LayoutChangeEvent, Platform, type ViewStyle, KeyboardAvoidingView, Keyboard, StyleSheet} from 'react-native';
import Animated, {useAnimatedStyle, useDerivedValue, withTiming, type AnimatedStyle} from 'react-native-reanimated';
import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
@@ -25,9 +25,10 @@ import {useTheme} from '@context/theme';
import {useKeyboardHeight} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {useCollapsibleHeader} from '@hooks/header';
import useTabs from '@hooks/use_tabs';
import NavigationStore from '@store/navigation_store';
import {type FileFilter, FileFilters, filterFileExtensions} from '@utils/file';
import {TabTypes, type TabType} from '@utils/search';
import {TabTypes} from '@utils/search';
import Initial from './initial';
import Results from './results';
@@ -81,6 +82,21 @@ const searchScreenIndex = 1;
const CHANNEL_AND_USER_FILTERS_REGEX = /(?:from|channel|in):\s?[^\s\n]+/gi;
const tabs = [{
name: defineMessage({
id: 'screen.search.header.messages',
defaultMessage: 'Messages',
}),
id: TabTypes.MESSAGES,
},
{
name: defineMessage({
id: 'screen.search.header.files',
defaultMessage: 'Files',
}),
id: TabTypes.FILES,
}];
const SearchScreen = ({teamId, teams, crossTeamSearchEnabled}: Props) => {
const nav = useNavigation();
const isFocused = useIsFocused();
@@ -100,7 +116,6 @@ const SearchScreen = ({teamId, teams, crossTeamSearchEnabled}: Props) => {
const [cursorPosition, setCursorPosition] = useState(searchTerm?.length || 0);
const [searchValue, setSearchValue] = useState<string>(searchTerm || '');
const [searchTeamId, setSearchTeamId] = useState<string>(teamId);
const [selectedTab, setSelectedTab] = useState<TabType>(TabTypes.MESSAGES);
const [filter, setFilter] = useState<FileFilter>(FileFilters.ALL);
const [showResults, setShowResults] = useState(false);
const [containerHeight, setContainerHeight] = useState(0);
@@ -114,6 +129,8 @@ const SearchScreen = ({teamId, teams, crossTeamSearchEnabled}: Props) => {
const [fileInfos, setFileInfos] = useState<FileInfo[]>(emptyFileResults);
const [fileChannelIds, setFileChannelIds] = useState<string[]>([]);
const [selectedTab, tabsProps] = useTabs(TabTypes.MESSAGES, tabs, undefined, 'search.tabs');
useEffect(() => {
setSearchTeamId(teamId);
}, [teamId]);
@@ -406,12 +423,12 @@ const SearchScreen = ({teamId, teams, crossTeamSearchEnabled}: Props) => {
<Header
teamId={searchTeamId}
setTeamId={handleResultsTeamChange}
onTabSelect={setSelectedTab}
onFilterChanged={handleFilterChange}
selectedTab={selectedTab}
selectedFilter={filter}
teams={teams}
crossTeamSearchEnabled={crossTeamSearchEnabled}
tabsProps={tabsProps}
/>
}
</Animated.View>

View File

@@ -13,9 +13,9 @@ class GlobalThreadsScreen {
testID = {
threadItemPrefix: 'global_threads.threads_list.thread_item.',
globalThreadsScreen: 'global_threads.screen',
headerAllThreadsButton: 'global_threads.threads_list.header.all_threads.button',
headerUnreadThreadsButton: 'global_threads.threads_list.header.unread_threads.button',
headerUnreadDotBadge: 'global_threads.threads_list.header.unread_dot.badge',
headerAllThreadsButton: 'global_threads.threads_list.header.all.button',
headerUnreadThreadsButton: 'global_threads.threads_list.header.unreads.button',
headerUnreadDotBadge: 'global_threads.threads_list.header.unreads.badge',
headerMarkAllAsReadButton: 'global_threads.threads_list.header.mark_all_as_read.button',
emptyThreadsList: 'global_threads.threads_list.empty_state',
flatThreadsList: 'global_threads.threads_list.flat_list',

View File

@@ -10,8 +10,8 @@ class ScheduledMessageScreen {
customDateTimePickerScreen: 'custom_date_time_picker',
deleteDraft: 'delete_draft',
rescheduleOption: 'rescheduled_draft',
scheduledTab: 'scheduled_post_tab',
scheduledTabBadgeCount: 'scheduled_post_count_badge',
scheduledTab: 'tabs.scheduled_posts.button',
scheduledTabBadgeCount: 'tabs.scheduled_posts.badge',
scheduledMessageTooltipCloseButton: 'draft.tooltip.close.button',
scheduledMessageText: 'markdown_paragraph',
scheduledDraftTime: 'scheduled_post_header.scheduled_at',