forked from Ivasoft/mattermost-mobile
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:
committed by
GitHub
parent
db569fe2c3
commit
6c53533080
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
9
app/hooks/use_tabs/index.ts
Normal file
9
app/hooks/use_tabs/index.ts
Normal 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};
|
||||
110
app/hooks/use_tabs/tab.test.tsx
Normal file
110
app/hooks/use_tabs/tab.test.tsx
Normal 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
121
app/hooks/use_tabs/tab.tsx
Normal 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;
|
||||
85
app/hooks/use_tabs/tabs.test.tsx
Normal file
85
app/hooks/use_tabs/tabs.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
56
app/hooks/use_tabs/tabs.tsx
Normal file
56
app/hooks/use_tabs/tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
app/hooks/use_tabs/types.ts
Normal file
11
app/hooks/use_tabs/types.ts
Normal 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;
|
||||
}
|
||||
81
app/hooks/use_tabs/use_tabs.test.tsx
Normal file
81
app/hooks/use_tabs/use_tabs.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
27
app/hooks/use_tabs/use_tabs.ts
Normal file
27
app/hooks/use_tabs/use_tabs.ts
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
148
app/screens/global_threads/global_threads.test.tsx
Normal file
148
app/screens/global_threads/global_threads.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
158
app/screens/global_threads/index.test.tsx
Normal file
158
app/screens/global_threads/index.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user