From 6c53533080d0dc049fd96084367c0e1e0ac4a183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Wed, 28 May 2025 16:23:37 +0200 Subject: [PATCH] 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 --- app/components/formatted_text/index.tsx | 6 +- app/constants/draft.ts | 4 +- app/hooks/use_tabs/index.ts | 9 + app/hooks/use_tabs/tab.test.tsx | 110 +++++++++++ app/hooks/use_tabs/tab.tsx | 121 ++++++++++++ app/hooks/use_tabs/tabs.test.tsx | 85 +++++++++ app/hooks/use_tabs/tabs.tsx | 56 ++++++ app/hooks/use_tabs/types.ts | 11 ++ app/hooks/use_tabs/use_tabs.test.tsx | 81 ++++++++ app/hooks/use_tabs/use_tabs.ts | 27 +++ .../draft_tabs_header.test.tsx | 69 ------- .../tabbed_contents/draft_tabs_header.tsx | 174 ------------------ .../tabbed_contents/tabbed_contents.test.tsx | 16 +- .../tabbed_contents/tabbed_contents.tsx | 43 +++-- .../global_drafts/global_drafts.test.tsx | 9 +- .../global_threads/global_threads.test.tsx | 148 +++++++++++++++ app/screens/global_threads/global_threads.tsx | 51 ++++- app/screens/global_threads/index.test.tsx | 158 ++++++++++++++++ app/screens/global_threads/index.tsx | 13 +- .../threads_list/header/index.test.tsx | 40 ++++ .../threads_list/header/index.tsx | 115 +----------- .../global_threads/threads_list/index.ts | 1 - .../threads_list/threads_list.tsx | 56 ++---- .../home/search/results/header.test.tsx | 89 ++++----- app/screens/home/search/results/header.tsx | 76 +++----- .../home/search/results/header_button.tsx | 61 ------ app/screens/home/search/search.test.tsx | 23 +++ app/screens/home/search/search.tsx | 25 ++- detox/e2e/support/ui/screen/global_threads.ts | 6 +- .../ui/screen/scheduled_message_screen.ts | 4 +- 30 files changed, 1081 insertions(+), 606 deletions(-) create mode 100644 app/hooks/use_tabs/index.ts create mode 100644 app/hooks/use_tabs/tab.test.tsx create mode 100644 app/hooks/use_tabs/tab.tsx create mode 100644 app/hooks/use_tabs/tabs.test.tsx create mode 100644 app/hooks/use_tabs/tabs.tsx create mode 100644 app/hooks/use_tabs/types.ts create mode 100644 app/hooks/use_tabs/use_tabs.test.tsx create mode 100644 app/hooks/use_tabs/use_tabs.ts delete mode 100644 app/screens/global_drafts/components/tabbed_contents/draft_tabs_header.test.tsx delete mode 100644 app/screens/global_drafts/components/tabbed_contents/draft_tabs_header.tsx create mode 100644 app/screens/global_threads/global_threads.test.tsx create mode 100644 app/screens/global_threads/index.test.tsx create mode 100644 app/screens/global_threads/threads_list/header/index.test.tsx delete mode 100644 app/screens/home/search/results/header_button.tsx diff --git a/app/components/formatted_text/index.tsx b/app/components/formatted_text/index.tsx index a9b591ace..274685264 100644 --- a/app/components/formatted_text/index.tsx +++ b/app/components/formatted_text/index.tsx @@ -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; testID?: string; style?: StyleProp; diff --git a/app/constants/draft.ts b/app/constants/draft.ts index cd5628450..12e302187 100644 --- a/app/constants/draft.ts +++ b/app/constants/draft.ts @@ -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; diff --git a/app/hooks/use_tabs/index.ts b/app/hooks/use_tabs/index.ts new file mode 100644 index 000000000..bfd44e29c --- /dev/null +++ b/app/hooks/use_tabs/index.ts @@ -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}; diff --git a/app/hooks/use_tabs/tab.test.tsx b/app/hooks/use_tabs/tab.test.tsx new file mode 100644 index 000000000..6f6259406 --- /dev/null +++ b/app/hooks/use_tabs/tab.test.tsx @@ -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( + , + ); + + expect(getByText('Test Tab')).toBeTruthy(); + }); + + it('shows selected state', () => { + const {getByText} = renderWithIntl( + , + ); + + const text = getByText('Test Tab'); + expect(text).toHaveStyle({color: Preferences.THEMES.denim.buttonBg}); + }); + + it('shows dot indicator when hasDot is true', () => { + const {getByTestId} = renderWithIntl( + , + ); + + expect(getByTestId('tabs.test.dot')).toBeTruthy(); + }); + + it('does not show dot indicator when hasDot is false', () => { + const {queryByTestId} = renderWithIntl( + , + ); + + expect(queryByTestId('tabs.test.dot')).toBeNull(); + }); + + it('calls handleTabChange when pressed', () => { + const handleTabChange = jest.fn(); + const {getByTestId} = renderWithIntl( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(queryByTestId('tabs.test.badge')).toBeNull(); + }); +}); diff --git a/app/hooks/use_tabs/tab.tsx b/app/hooks/use_tabs/tab.tsx new file mode 100644 index 000000000..2a1479bc2 --- /dev/null +++ b/app/hooks/use_tabs/tab.tsx @@ -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 = { + name: MessageDescriptor; + id: T; + requiresUserAttention?: boolean; + handleTabChange: (value: T) => void; + isSelected: boolean; + count?: number; + testID: string; +} + +const Tab = ({ + name, + id, + requiresUserAttention, + handleTabChange, + isSelected, + count, + testID, +}: TabProps) => { + 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 ( + + + + {count ? ( + + ) : null} + {requiresUserAttention ? ( + + ) : null} + + + ); +}; + +export default Tab; diff --git a/app/hooks/use_tabs/tabs.test.tsx b/app/hooks/use_tabs/tabs.test.tsx new file mode 100644 index 000000000..6ac89d424 --- /dev/null +++ b/app/hooks/use_tabs/tabs.test.tsx @@ -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 = { + 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( + , + ); + + 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( + , + ); + + const tabs = getAllByTestId('tabs'); + expect(tabs.length).toBe(2); + }); +}); diff --git a/app/hooks/use_tabs/tabs.tsx b/app/hooks/use_tabs/tabs.tsx new file mode 100644 index 000000000..1497b41cb --- /dev/null +++ b/app/hooks/use_tabs/tabs.tsx @@ -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 = { + tabs: Array>; + 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({ + tabs, + selectedTab, + onTabChange, + testID, +}: Props) { + const tabsComponents = tabs.map(({name, id, requiresUserAttention, count}) => { + const isSelected = selectedTab === id; + return ( + + ); + }); + + return ( + + {tabsComponents} + + ); +} diff --git a/app/hooks/use_tabs/types.ts b/app/hooks/use_tabs/types.ts new file mode 100644 index 000000000..a226a7b9e --- /dev/null +++ b/app/hooks/use_tabs/types.ts @@ -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 = { + name: MessageDescriptor; + id: T; + requiresUserAttention?: boolean; + count?: number; +} diff --git a/app/hooks/use_tabs/use_tabs.test.tsx b/app/hooks/use_tabs/use_tabs.test.tsx new file mode 100644 index 000000000..1b58a0a5a --- /dev/null +++ b/app/hooks/use_tabs/use_tabs.test.tsx @@ -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> = [ + {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> = [ + {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> = [ + {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); + }); +}); + diff --git a/app/hooks/use_tabs/use_tabs.ts b/app/hooks/use_tabs/use_tabs.ts new file mode 100644 index 000000000..739d73845 --- /dev/null +++ b/app/hooks/use_tabs/use_tabs.ts @@ -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(defaultTab: T, tabs: Array>, changeCallback?: (value: T) => void, testID?: string) { + const [tab, setTab] = useState(defaultTab); + + const handleTabChange = useCallback((value: T) => { + setTab(value); + changeCallback?.(value); + }, [changeCallback]); + + const tabsProps = useMemo>(() => ({ + tabs, + selectedTab: tab, + onTabChange: handleTabChange, + testID, + }), [tabs, tab, handleTabChange, testID]); + + return [tab, tabsProps] as const; +} + +export default useTabs; diff --git a/app/screens/global_drafts/components/tabbed_contents/draft_tabs_header.test.tsx b/app/screens/global_drafts/components/tabbed_contents/draft_tabs_header.test.tsx deleted file mode 100644 index 952874252..000000000 --- a/app/screens/global_drafts/components/tabbed_contents/draft_tabs_header.test.tsx +++ /dev/null @@ -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[0] = { - draftsCount: 5, - scheduledPostCount: 3, - selectedTab: 0, - onTabChange: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders correctly with default props', async () => { - renderWithIntlAndTheme(); - - 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( - , - ); - - 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(); - - 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); - }); -}); diff --git a/app/screens/global_drafts/components/tabbed_contents/draft_tabs_header.tsx b/app/screens/global_drafts/components/tabbed_contents/draft_tabs_header.tsx deleted file mode 100644 index 89c290608..000000000 --- a/app/screens/global_drafts/components/tabbed_contents/draft_tabs_header.tsx +++ /dev/null @@ -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 ( - - ); - }; - - const scheduledPostCountBadge = () => { - const style = [styles.badgeStyles, selectedTab === DRAFT_SCREEN_TAB_SCHEDULED_POSTS ? styles.activeBadgeStyles : null]; - - return ( - - ); - }; - - 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 ( - - - - - - {draftCountBadge()} - - - - - - {scheduledPostCountBadge()} - - - - - ); -} diff --git a/app/screens/global_drafts/components/tabbed_contents/tabbed_contents.test.tsx b/app/screens/global_drafts/components/tabbed_contents/tabbed_contents.test.tsx index db8d0d8a6..86e773266 100644 --- a/app/screens/global_drafts/components/tabbed_contents/tabbed_contents.test.tsx +++ b/app/screens/global_drafts/components/tabbed_contents/tabbed_contents.test.tsx @@ -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); diff --git a/app/screens/global_drafts/components/tabbed_contents/tabbed_contents.tsx b/app/screens/global_drafts/components/tabbed_contents/tabbed_contents.tsx index 8c67fab67..645c0bd72 100644 --- a/app/screens/global_drafts/components/tabbed_contents/tabbed_contents.tsx +++ b/app/screens/global_drafts/components/tabbed_contents/tabbed_contents.tsx @@ -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 ( - - + + + { 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 () => { diff --git a/app/screens/global_threads/global_threads.test.tsx b/app/screens/global_threads/global_threads.test.tsx new file mode 100644 index 000000000..3458964b3 --- /dev/null +++ b/app/screens/global_threads/global_threads.test.tsx @@ -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(, { + 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( + , + { + 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( + , + ); + + 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(, { + 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(, { + database, + }); + + unmount(); + + expect(setGlobalThreadsTab).toHaveBeenCalledWith(expect.any(String), 'all'); + }); +}); diff --git a/app/screens/global_threads/global_threads.tsx b/app/screens/global_threads/global_threads.tsx index 8eaa4596b..0d912d27f 100644 --- a/app/screens/global_threads/global_threads.tsx +++ b/app/screens/global_threads/global_threads.tsx @@ -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>(null); + const defaultHeight = useDefaultHeaderHeight(); - const [tab, setTab] = useState(globalThreadsTab); + const tabs = useMemo>>(() => [ + { + 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, tabs, tabOnChange, 'global_threads.threads_list.header'); const mounted = useRef(false); const containerStyle = useMemo(() => { @@ -104,10 +135,16 @@ const GlobalThreads = ({componentId, globalThreadsTab}: Props) => { {!switchingTeam && +
} diff --git a/app/screens/global_threads/index.test.tsx b/app/screens/global_threads/index.test.tsx new file mode 100644 index 000000000..cc10ad946 --- /dev/null +++ b/app/screens/global_threads/index.test.tsx @@ -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(, {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(, {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(, {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(, {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'); + }); +}); diff --git a/app/screens/global_threads/index.tsx b/app/screens/global_threads/index.tsx index 705457373..4ba95d76e 100644 --- a/app/screens/global_threads/index.tsx +++ b/app/screens/global_threads/index.tsx @@ -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), }; }); diff --git a/app/screens/global_threads/threads_list/header/index.test.tsx b/app/screens/global_threads/threads_list/header/index.test.tsx new file mode 100644 index 000000000..1b3166f62 --- /dev/null +++ b/app/screens/global_threads/threads_list/header/index.test.tsx @@ -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 = { + 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( +
, + ); + + // Verify the tab component is rendered + expect(getByTestId('testID.tab1.button')).toBeTruthy(); + }); +}); + diff --git a/app/screens/global_threads/threads_list/header/index.tsx b/app/screens/global_threads/threads_list/header/index.tsx index ffcbd7d79..6f1c743f1 100644 --- a/app/screens/global_threads/threads_list/header/index.tsx +++ b/app/screens/global_threads/threads_list/header/index.tsx @@ -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; 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 ( - - - - - - - - - - - {hasUnreads ? ( - - ) : null} - - - - + { diff --git a/app/screens/global_threads/threads_list/threads_list.tsx b/app/screens/global_threads/threads_list/threads_list.tsx index c5b74890d..c6b92b6a6 100644 --- a/app/screens/global_threads/threads_list/threads_list.tsx +++ b/app/screens/global_threads/threads_list/threads_list.tsx @@ -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>; }; 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>(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 ( - <> -
- - + ); }; diff --git a/app/screens/home/search/results/header.test.tsx b/app/screens/home/search/results/header.test.tsx index 49dc792b2..b1019330a 100644 --- a/app/screens/home/search/results/header.test.tsx +++ b/app/screens/home/search/results/header.test.tsx @@ -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( -
, - ); - - expect(getByText('Messages')).toBeTruthy(); - expect(getByText('Files')).toBeTruthy(); - }); - - it('should call onTabSelect with MESSAGES when Messages button is pressed', () => { - const {getByText} = renderWithIntlAndTheme( -
, - ); - - fireEvent.press(getByText('Messages')); - expect(onTabSelect).toHaveBeenCalledWith(TabTypes.MESSAGES); - }); - - it('should call onTabSelect with FILES when Files button is pressed', () => { - const {getByText} = renderWithIntlAndTheme( -
, - ); - - 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(
, ); @@ -100,12 +56,12 @@ describe('Header', () => {
, ); @@ -120,12 +76,12 @@ describe('Header', () => {
, ); @@ -135,4 +91,21 @@ describe('Header', () => { expect(queryByTestId('search.filters.file_type_icon')).toBeFalsy(); } }); + + it('should render the provided tabsComponent', () => { + const {getByTestId} = renderWithIntlAndTheme( +
, + ); + + expect(getByTestId('testID.tab1.button')).toBeTruthy(); + }); }); diff --git a/app/screens/home/search/results/header.tsx b/app/screens/home/search/results/header.tsx index b3f606cfb..b73013363 100644 --- a/app/screens/home/search/results/header.tsx +++ b/app/screens/home/search/results/header.tsx @@ -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; } 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 ( - - - - + {showFilterIcon && ( - - + + + + )} - - {teams.length > 1 && ( + {teams.length > 1 && ( + - )} - + + )} + ); diff --git a/app/screens/home/search/results/header_button.tsx b/app/screens/home/search/results/header_button.tsx deleted file mode 100644 index 73753a3e4..000000000 --- a/app/screens/home/search/results/header_button.tsx +++ /dev/null @@ -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 ( - - ); -}; - -export default SelectButton; diff --git a/app/screens/home/search/search.test.tsx b/app/screens/home/search/search.test.tsx index 3a25d0111..bb9f0c344 100644 --- a/app/screens/home/search/search.test.tsx +++ b/app/screens/home/search/search.test.tsx @@ -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( + , + {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), + ); + }); }); diff --git a/app/screens/home/search/search.tsx b/app/screens/home/search/search.tsx index 2c195c0e0..685164399 100644 --- a/app/screens/home/search/search.tsx +++ b/app/screens/home/search/search.tsx @@ -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(searchTerm || ''); const [searchTeamId, setSearchTeamId] = useState(teamId); - const [selectedTab, setSelectedTab] = useState(TabTypes.MESSAGES); const [filter, setFilter] = useState(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(emptyFileResults); const [fileChannelIds, setFileChannelIds] = useState([]); + const [selectedTab, tabsProps] = useTabs(TabTypes.MESSAGES, tabs, undefined, 'search.tabs'); + useEffect(() => { setSearchTeamId(teamId); }, [teamId]); @@ -406,12 +423,12 @@ const SearchScreen = ({teamId, teams, crossTeamSearchEnabled}: Props) => {
} diff --git a/detox/e2e/support/ui/screen/global_threads.ts b/detox/e2e/support/ui/screen/global_threads.ts index bd15ec91a..081494547 100644 --- a/detox/e2e/support/ui/screen/global_threads.ts +++ b/detox/e2e/support/ui/screen/global_threads.ts @@ -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', diff --git a/detox/e2e/support/ui/screen/scheduled_message_screen.ts b/detox/e2e/support/ui/screen/scheduled_message_screen.ts index c76d8e4a0..b640ee69f 100644 --- a/detox/e2e/support/ui/screen/scheduled_message_screen.ts +++ b/detox/e2e/support/ui/screen/scheduled_message_screen.ts @@ -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',