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:
parent
db569fe2c3
commit
6c53533080
30 changed files with 1081 additions and 606 deletions
|
|
@ -2,14 +2,14 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {createElement, isValidElement} from 'react';
|
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 {type StyleProp, Text, type TextProps, type TextStyle} from 'react-native';
|
||||||
|
|
||||||
import {generateId} from '@utils/general';
|
import {generateId} from '@utils/general';
|
||||||
|
|
||||||
type FormattedTextProps = TextProps & {
|
type FormattedTextProps = TextProps & {
|
||||||
id: string;
|
id?: MessageDescriptor['id'];
|
||||||
defaultMessage?: string;
|
defaultMessage?: MessageDescriptor['defaultMessage'];
|
||||||
values?: Record<string, any>;
|
values?: Record<string, any>;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
style?: StyleProp<TextStyle>;
|
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_SCHEDULED_POST_LAYOUT_PADDING = 40;
|
||||||
|
|
||||||
export const DRAFT_SCREEN_TAB_DRAFTS = 0;
|
export const DRAFT_SCREEN_TAB_DRAFTS = 'drafts' as const;
|
||||||
export const DRAFT_SCREEN_TAB_SCHEDULED_POSTS = 1;
|
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;
|
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
|
await TestHelper.wait(300); // Wait until the badge renders
|
||||||
});
|
});
|
||||||
|
|
||||||
const scheduledTab = screen.getByTestId('scheduled_post_tab');
|
const scheduledTab = screen.getByTestId('tabs.scheduled_posts.button');
|
||||||
const draftTab = screen.getByTestId('draft_tab');
|
const draftTab = screen.getByTestId('tabs.drafts.button');
|
||||||
|
|
||||||
// Check that the drafts tab is selected
|
// Check that the drafts tab is selected
|
||||||
expect(scheduledTab.props.accessibilityState).toEqual({selected: false});
|
expect(scheduledTab.props.accessibilityState).toEqual({selected: false});
|
||||||
|
|
@ -86,8 +86,8 @@ describe('TabbedContents', () => {
|
||||||
await TestHelper.wait(300);
|
await TestHelper.wait(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
const scheduledTab = screen.getByTestId('scheduled_post_tab');
|
const scheduledTab = screen.getByTestId('tabs.scheduled_posts.button');
|
||||||
const draftTab = screen.getByTestId('draft_tab');
|
const draftTab = screen.getByTestId('tabs.drafts.button');
|
||||||
|
|
||||||
// ✅ Check accessibilityState
|
// ✅ Check accessibilityState
|
||||||
expect(scheduledTab.props.accessibilityState).toEqual({selected: true});
|
expect(scheduledTab.props.accessibilityState).toEqual({selected: true});
|
||||||
|
|
@ -105,19 +105,19 @@ describe('TabbedContents', () => {
|
||||||
await TestHelper.wait(300); // Wait until the badge renders
|
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();
|
expect(screen.getByTestId('drafts-content')).toBeTruthy();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.press(screen.getByTestId('scheduled_post_tab'));
|
fireEvent.press(screen.getByTestId('tabs.scheduled_posts.button'));
|
||||||
await TestHelper.wait(0);
|
await TestHelper.wait(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByTestId('scheduled-posts-content')).toBeTruthy();
|
expect(screen.getByTestId('scheduled-posts-content')).toBeTruthy();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.press(screen.getByTestId('draft_tab'));
|
fireEvent.press(screen.getByTestId('tabs.drafts.button'));
|
||||||
await TestHelper.wait(0);
|
await TestHelper.wait(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -149,7 +149,7 @@ describe('TabbedContents', () => {
|
||||||
|
|
||||||
// Click on scheduled posts tab to make that content visible
|
// Click on scheduled posts tab to make that content visible
|
||||||
await act(async () => {
|
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
|
// Add a small delay to allow animations to complete
|
||||||
await TestHelper.wait(0);
|
await TestHelper.wait(0);
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@
|
||||||
|
|
||||||
import React, {type ReactNode, useState, useMemo, useCallback} from 'react';
|
import React, {type ReactNode, useState, useMemo, useCallback} from 'react';
|
||||||
import {Freeze} from 'react-freeze';
|
import {Freeze} from 'react-freeze';
|
||||||
|
import {defineMessage} from 'react-intl';
|
||||||
import {StyleSheet, View, type LayoutChangeEvent} from 'react-native';
|
import {StyleSheet, View, type LayoutChangeEvent} from 'react-native';
|
||||||
import Animated, {runOnJS, useAnimatedStyle, withTiming} from 'react-native-reanimated';
|
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 {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;
|
const duration = 250;
|
||||||
|
|
||||||
|
|
@ -38,7 +40,25 @@ const getStyleSheet = (width: number) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TabbedContents({draftsCount, scheduledPostCount, initialTab, drafts, scheduledPosts}: Props) {
|
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 [freezeDraft, setFreezeDraft] = useState(initialTab !== DRAFT_SCREEN_TAB_DRAFTS);
|
||||||
const [freezeScheduledPosts, setFreezeScheduledPosts] = useState(initialTab !== DRAFT_SCREEN_TAB_SCHEDULED_POSTS);
|
const [freezeScheduledPosts, setFreezeScheduledPosts] = useState(initialTab !== DRAFT_SCREEN_TAB_SCHEDULED_POSTS);
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
|
|
@ -69,28 +89,15 @@ export default function TabbedContents({draftsCount, scheduledPostCount, initial
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const onSelectTab = useCallback((tab: DraftScreenTab) => {
|
|
||||||
setSelectedTab(tab);
|
|
||||||
if (tab === DRAFT_SCREEN_TAB_DRAFTS) {
|
|
||||||
setFreezeDraft(false);
|
|
||||||
} else {
|
|
||||||
setFreezeScheduledPosts(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={styles.tabContainer}
|
style={styles.tabContainer}
|
||||||
onLayout={onLayout}
|
onLayout={onLayout}
|
||||||
testID='tabbed_contents'
|
testID='tabbed_contents'
|
||||||
>
|
>
|
||||||
<DraftTabsHeader
|
<View>
|
||||||
draftsCount={draftsCount}
|
<Tabs {...tabProps}/>
|
||||||
scheduledPostCount={scheduledPostCount}
|
</View>
|
||||||
selectedTab={selectedTab}
|
|
||||||
onTabChange={onSelectTab}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Animated.View style={[styles.tabContentContainer]}>
|
<Animated.View style={[styles.tabContentContainer]}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[firstTabStyle, styles.tabContent]}
|
style={[firstTabStyle, styles.tabContent]}
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,8 @@ describe('screens/global_drafts', () => {
|
||||||
await TestHelper.wait(200); // Wait until the badge renders
|
await TestHelper.wait(200); // Wait until the badge renders
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getByTestId('draft_tab_container')).toBeVisible();
|
expect(getByTestId('tabs.drafts.button')).toBeVisible();
|
||||||
expect(getByTestId('draft_tab')).toBeVisible();
|
expect(getByTestId('tabs.scheduled_posts.button')).toBeVisible();
|
||||||
expect(getByTestId('scheduled_post_tab')).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should switch between tabs', async () => {
|
it('should switch between tabs', async () => {
|
||||||
|
|
@ -62,8 +61,8 @@ describe('screens/global_drafts', () => {
|
||||||
await TestHelper.wait(200); // Wait until the badge renders
|
await TestHelper.wait(200); // Wait until the badge renders
|
||||||
});
|
});
|
||||||
|
|
||||||
const draftTab = getByTestId('draft_tab');
|
const draftTab = getByTestId('tabs.drafts.button');
|
||||||
const scheduledTab = getByTestId('scheduled_post_tab');
|
const scheduledTab = getByTestId('tabs.scheduled_posts.button');
|
||||||
const tabbedContents = getByTestId('tabbed_contents');
|
const tabbedContents = getByTestId('tabbed_contents');
|
||||||
|
|
||||||
await act(async () => {
|
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.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
|
||||||
import {useIntl} from 'react-intl';
|
import {defineMessage, useIntl} from 'react-intl';
|
||||||
import {Keyboard, StyleSheet, View} from 'react-native';
|
import {FlatList, Keyboard, StyleSheet, View} from 'react-native';
|
||||||
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
|
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import {setGlobalThreadsTab} from '@actions/local/systems';
|
import {setGlobalThreadsTab} from '@actions/local/systems';
|
||||||
|
|
@ -16,16 +16,21 @@ import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||||
import {useIsTablet} from '@hooks/device';
|
import {useIsTablet} from '@hooks/device';
|
||||||
import {useDefaultHeaderHeight} from '@hooks/header';
|
import {useDefaultHeaderHeight} from '@hooks/header';
|
||||||
import {useTeamSwitch} from '@hooks/team_switch';
|
import {useTeamSwitch} from '@hooks/team_switch';
|
||||||
|
import useTabs, {type TabDefinition} from '@hooks/use_tabs';
|
||||||
import SecurityManager from '@managers/security_manager';
|
import SecurityManager from '@managers/security_manager';
|
||||||
import {popTopScreen} from '@screens/navigation';
|
import {popTopScreen} from '@screens/navigation';
|
||||||
|
|
||||||
import ThreadsList from './threads_list';
|
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';
|
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
componentId?: AvailableScreens;
|
componentId?: AvailableScreens;
|
||||||
globalThreadsTab: GlobalThreadsTab;
|
globalThreadsTab: GlobalThreadsTab;
|
||||||
|
hasUnreads: boolean;
|
||||||
|
teamId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const edges: Edge[] = ['left', 'right'];
|
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 serverUrl = useServerUrl();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const switchingTeam = useTeamSwitch();
|
const switchingTeam = useTeamSwitch();
|
||||||
const isTablet = useIsTablet();
|
const isTablet = useIsTablet();
|
||||||
|
|
||||||
|
const flatListRef = useRef<FlatList<ThreadModel>>(null);
|
||||||
|
|
||||||
const defaultHeight = useDefaultHeaderHeight();
|
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 mounted = useRef(false);
|
||||||
|
|
||||||
const containerStyle = useMemo(() => {
|
const containerStyle = useMemo(() => {
|
||||||
|
|
@ -104,10 +135,16 @@ const GlobalThreads = ({componentId, globalThreadsTab}: Props) => {
|
||||||
</View>
|
</View>
|
||||||
{!switchingTeam &&
|
{!switchingTeam &&
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
|
<Header
|
||||||
|
teamId={teamId}
|
||||||
|
testID={`${testID}.header`}
|
||||||
|
hasUnreads={hasUnreads}
|
||||||
|
tabsProps={tabsProps}
|
||||||
|
/>
|
||||||
<ThreadsList
|
<ThreadsList
|
||||||
setTab={setTab}
|
|
||||||
tab={tab}
|
tab={tab}
|
||||||
testID={'global_threads.threads_list'}
|
testID={testID}
|
||||||
|
flatListRef={flatListRef}
|
||||||
/>
|
/>
|
||||||
</View>
|
</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.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
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 GlobalThreads from './global_threads';
|
||||||
|
|
||||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||||
|
|
||||||
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
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 {
|
return {
|
||||||
|
teamId,
|
||||||
|
hasUnreads,
|
||||||
globalThreadsTab: observeGlobalThreadsTab(database),
|
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.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// 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 {useIntl} from 'react-intl';
|
||||||
import {Alert, TouchableOpacity, View} from 'react-native';
|
import {Alert, TouchableOpacity, View} from 'react-native';
|
||||||
|
|
||||||
import {updateTeamThreadsAsRead} from '@actions/remote/thread';
|
import {updateTeamThreadsAsRead} from '@actions/remote/thread';
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
import FormattedText from '@components/formatted_text';
|
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import {useTheme} from '@context/theme';
|
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 {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
import {typography} from '@utils/typography';
|
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
setTab: (tab: GlobalThreadsTab) => void;
|
tabsProps: ComponentProps<typeof Tabs>;
|
||||||
tab: GlobalThreadsTab;
|
|
||||||
teamId: string;
|
teamId: string;
|
||||||
testID: string;
|
testID: string;
|
||||||
unreadsCount: number;
|
hasUnreads: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||||
|
|
@ -29,41 +27,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
flexDirection: 'row',
|
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: {
|
markAllReadIconContainer: {
|
||||||
paddingHorizontal: 20,
|
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 theme = useTheme();
|
||||||
const styles = getStyleSheet(theme);
|
const styles = getStyleSheet(theme);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const serverUrl = useServerUrl();
|
const serverUrl = useServerUrl();
|
||||||
|
|
||||||
const hasUnreads = unreadsCount > 0;
|
const handleMarkAllAsRead = usePreventDoubleTap(useCallback(() => {
|
||||||
const viewingUnreads = tab === 'unreads';
|
|
||||||
|
|
||||||
const handleMarkAllAsRead = useCallback(preventDoubleTap(() => {
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
intl.formatMessage({
|
intl.formatMessage({
|
||||||
id: 'global_threads.markAllRead.title',
|
id: 'global_threads.markAllRead.title',
|
||||||
|
|
@ -114,31 +74,7 @@ const Header = ({setTab, tab, teamId, testID, unreadsCount}: Props) => {
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
}), [intl, serverUrl, teamId]);
|
}, [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]);
|
|
||||||
|
|
||||||
const markAllStyle = useMemo(() => [
|
const markAllStyle = useMemo(() => [
|
||||||
styles.markAllReadIcon,
|
styles.markAllReadIcon,
|
||||||
|
|
@ -147,40 +83,7 @@ const Header = ({setTab, tab, teamId, testID, unreadsCount}: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.menuContainer}>
|
<Tabs {...tabsProps}/>
|
||||||
<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>
|
|
||||||
<View style={styles.markAllReadIconContainer}>
|
<View style={styles.markAllReadIconContainer}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={!hasUnreads}
|
disabled={!hasUnreads}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ const enhanced = withObservables(['tab', 'teamId'], ({database, tab, teamId}: Pr
|
||||||
const teamThreadsSyncObserver = queryTeamThreadsSync(database, teamId).observeWithColumns(['earliest']);
|
const teamThreadsSyncObserver = queryTeamThreadsSync(database, teamId).observeWithColumns(['earliest']);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
unreadsCount: queryThreadsInTeam(database, teamId, true, true, true).observeCount(false),
|
|
||||||
teammateNameDisplay: observeTeammateNameDisplay(database),
|
teammateNameDisplay: observeTeammateNameDisplay(database),
|
||||||
threads: teamThreadsSyncObserver.pipe(
|
threads: teamThreadsSyncObserver.pipe(
|
||||||
switchMap((teamThreadsSync) => {
|
switchMap((teamThreadsSync) => {
|
||||||
|
|
|
||||||
|
|
@ -12,19 +12,17 @@ import {useTheme} from '@context/theme';
|
||||||
|
|
||||||
import EmptyState from './empty_state';
|
import EmptyState from './empty_state';
|
||||||
import EndOfList from './end_of_list';
|
import EndOfList from './end_of_list';
|
||||||
import Header from './header';
|
|
||||||
import Thread from './thread';
|
import Thread from './thread';
|
||||||
|
|
||||||
import type ThreadModel from '@typings/database/models/servers/thread';
|
import type ThreadModel from '@typings/database/models/servers/thread';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
setTab: (tab: GlobalThreadsTab) => void;
|
|
||||||
tab: GlobalThreadsTab;
|
tab: GlobalThreadsTab;
|
||||||
teamId: string;
|
teamId: string;
|
||||||
teammateNameDisplay: string;
|
teammateNameDisplay: string;
|
||||||
testID: string;
|
testID: string;
|
||||||
threads: ThreadModel[];
|
threads: ThreadModel[];
|
||||||
unreadsCount: number;
|
flatListRef: React.RefObject<FlatList<ThreadModel>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
|
@ -46,18 +44,16 @@ const styles = StyleSheet.create({
|
||||||
});
|
});
|
||||||
|
|
||||||
const ThreadsList = ({
|
const ThreadsList = ({
|
||||||
setTab,
|
|
||||||
tab,
|
tab,
|
||||||
teamId,
|
teamId,
|
||||||
teammateNameDisplay,
|
teammateNameDisplay,
|
||||||
testID,
|
testID,
|
||||||
threads,
|
threads,
|
||||||
unreadsCount,
|
flatListRef,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const serverUrl = useServerUrl();
|
const serverUrl = useServerUrl();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const flatListRef = useRef<FlatList<ThreadModel>>(null);
|
|
||||||
const hasFetchedOnce = useRef(false);
|
const hasFetchedOnce = useRef(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [endReached, setEndReached] = useState(false);
|
const [endReached, setEndReached] = useState(false);
|
||||||
|
|
@ -97,8 +93,10 @@ const ThreadsList = ({
|
||||||
);
|
);
|
||||||
}, [isLoading, theme, tab]);
|
}, [isLoading, theme, tab]);
|
||||||
|
|
||||||
|
const hasThreads = threads.length > 0;
|
||||||
|
|
||||||
const listFooterComponent = useMemo(() => {
|
const listFooterComponent = useMemo(() => {
|
||||||
if (tab === 'unreads' || !threads.length) {
|
if (tab === 'unreads' || !hasThreads) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,12 +114,7 @@ const ThreadsList = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [isLoading, tab, theme, endReached]);
|
}, [tab, hasThreads, endReached, isLoading, theme.buttonBg]);
|
||||||
|
|
||||||
const handleTabChange = useCallback((value: GlobalThreadsTab) => {
|
|
||||||
setTab(value);
|
|
||||||
flatListRef.current?.scrollToOffset({animated: true, offset: 0});
|
|
||||||
}, [setTab]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
|
|
@ -157,29 +150,20 @@ const ThreadsList = ({
|
||||||
), [teammateNameDisplay, testID]);
|
), [teammateNameDisplay, testID]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<FlatList
|
||||||
<Header
|
ListEmptyComponent={listEmptyComponent}
|
||||||
setTab={handleTabChange}
|
ListFooterComponent={listFooterComponent}
|
||||||
tab={tab}
|
contentContainerStyle={threads.length ? styles.messagesContainer : styles.empty}
|
||||||
teamId={teamId}
|
data={threads}
|
||||||
testID={`${testID}.header`}
|
maxToRenderPerBatch={10}
|
||||||
unreadsCount={unreadsCount}
|
onEndReached={handleEndReached}
|
||||||
/>
|
onRefresh={handleRefresh}
|
||||||
<FlatList
|
ref={flatListRef}
|
||||||
ListEmptyComponent={listEmptyComponent}
|
refreshing={isRefreshing}
|
||||||
ListFooterComponent={listFooterComponent}
|
removeClippedSubviews={true}
|
||||||
contentContainerStyle={threads.length ? styles.messagesContainer : styles.empty}
|
renderItem={renderItem}
|
||||||
data={threads}
|
testID={`${testID}.flat_list`}
|
||||||
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 React from 'react';
|
||||||
|
|
||||||
import {renderWithIntlAndTheme, fireEvent} from '@test/intl-test-helper';
|
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||||
import {FileFilters} from '@utils/file';
|
import {FileFilters} from '@utils/file';
|
||||||
import {TabTypes} from '@utils/search';
|
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', () => ({}));
|
jest.mock('@react-native-camera-roll/camera-roll', () => ({}));
|
||||||
|
|
||||||
describe('Header', () => {
|
describe('Header', () => {
|
||||||
const onTabSelect = jest.fn();
|
|
||||||
const onFilterChanged = jest.fn();
|
const onFilterChanged = jest.fn();
|
||||||
const setTeamId = jest.fn();
|
const setTeamId = jest.fn();
|
||||||
|
|
||||||
|
|
@ -24,71 +23,28 @@ describe('Header', () => {
|
||||||
{id: 'team2', displayName: 'Team 2'},
|
{id: 'team2', displayName: 'Team 2'},
|
||||||
] as TeamModel[];
|
] as TeamModel[];
|
||||||
|
|
||||||
it('should render correctly', () => {
|
const baseTabsProps = {
|
||||||
const {getByText} = renderWithIntlAndTheme(
|
tabs: [{
|
||||||
<Header
|
id: 'tab1',
|
||||||
teamId='team1'
|
name: {id: 'tab1.name', defaultMessage: 'Tab 1'},
|
||||||
setTeamId={setTeamId}
|
hasDot: true,
|
||||||
onTabSelect={onTabSelect}
|
}],
|
||||||
onFilterChanged={onFilterChanged}
|
selectedTab: 'tab1',
|
||||||
selectedTab={TabTypes.MESSAGES}
|
onTabChange: jest.fn(),
|
||||||
selectedFilter={FileFilters.ALL}
|
testID: 'testID',
|
||||||
teams={teams}
|
};
|
||||||
crossTeamSearchEnabled={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(getByText('Messages')).toBeTruthy();
|
|
||||||
expect(getByText('Files')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onTabSelect with MESSAGES when Messages button is pressed', () => {
|
|
||||||
const {getByText} = renderWithIntlAndTheme(
|
|
||||||
<Header
|
|
||||||
teamId='team1'
|
|
||||||
setTeamId={setTeamId}
|
|
||||||
onTabSelect={onTabSelect}
|
|
||||||
onFilterChanged={onFilterChanged}
|
|
||||||
selectedTab={TabTypes.MESSAGES}
|
|
||||||
selectedFilter={FileFilters.ALL}
|
|
||||||
teams={teams}
|
|
||||||
crossTeamSearchEnabled={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.press(getByText('Messages'));
|
|
||||||
expect(onTabSelect).toHaveBeenCalledWith(TabTypes.MESSAGES);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onTabSelect with FILES when Files button is pressed', () => {
|
|
||||||
const {getByText} = renderWithIntlAndTheme(
|
|
||||||
<Header
|
|
||||||
teamId='team1'
|
|
||||||
setTeamId={setTeamId}
|
|
||||||
onTabSelect={onTabSelect}
|
|
||||||
onFilterChanged={onFilterChanged}
|
|
||||||
selectedTab={TabTypes.MESSAGES}
|
|
||||||
selectedFilter={FileFilters.ALL}
|
|
||||||
teams={teams}
|
|
||||||
crossTeamSearchEnabled={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.press(getByText('Files'));
|
|
||||||
expect(onTabSelect).toHaveBeenCalledWith(TabTypes.FILES);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not render TeamPicker when there is only one team', () => {
|
it('should not render TeamPicker when there is only one team', () => {
|
||||||
const {queryByText} = renderWithIntlAndTheme(
|
const {queryByText} = renderWithIntlAndTheme(
|
||||||
<Header
|
<Header
|
||||||
teamId='team1'
|
teamId='team1'
|
||||||
setTeamId={setTeamId}
|
setTeamId={setTeamId}
|
||||||
onTabSelect={onTabSelect}
|
|
||||||
onFilterChanged={onFilterChanged}
|
onFilterChanged={onFilterChanged}
|
||||||
selectedTab={TabTypes.MESSAGES}
|
selectedTab={TabTypes.MESSAGES}
|
||||||
selectedFilter={FileFilters.ALL}
|
selectedFilter={FileFilters.ALL}
|
||||||
teams={[teams[0]]}
|
teams={[teams[0]]}
|
||||||
crossTeamSearchEnabled={false}
|
crossTeamSearchEnabled={false}
|
||||||
|
tabsProps={baseTabsProps}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -100,12 +56,12 @@ describe('Header', () => {
|
||||||
<Header
|
<Header
|
||||||
teamId='team1'
|
teamId='team1'
|
||||||
setTeamId={setTeamId}
|
setTeamId={setTeamId}
|
||||||
onTabSelect={onTabSelect}
|
|
||||||
onFilterChanged={onFilterChanged}
|
onFilterChanged={onFilterChanged}
|
||||||
selectedTab={TabTypes.MESSAGES}
|
selectedTab={TabTypes.MESSAGES}
|
||||||
selectedFilter={FileFilters.ALL}
|
selectedFilter={FileFilters.ALL}
|
||||||
teams={teams}
|
teams={teams}
|
||||||
crossTeamSearchEnabled={false}
|
crossTeamSearchEnabled={false}
|
||||||
|
tabsProps={baseTabsProps}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -120,12 +76,12 @@ describe('Header', () => {
|
||||||
<Header
|
<Header
|
||||||
teamId='team1'
|
teamId='team1'
|
||||||
setTeamId={setTeamId}
|
setTeamId={setTeamId}
|
||||||
onTabSelect={onTabSelect}
|
|
||||||
onFilterChanged={onFilterChanged}
|
onFilterChanged={onFilterChanged}
|
||||||
selectedTab={selectedTab}
|
selectedTab={selectedTab}
|
||||||
selectedFilter={FileFilters.ALL}
|
selectedFilter={FileFilters.ALL}
|
||||||
teams={teams}
|
teams={teams}
|
||||||
crossTeamSearchEnabled={false}
|
crossTeamSearchEnabled={false}
|
||||||
|
tabsProps={baseTabsProps}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -135,4 +91,21 @@ describe('Header', () => {
|
||||||
expect(queryByTestId('search.filters.file_type_icon')).toBeFalsy();
|
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.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// 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 {useIntl} from 'react-intl';
|
||||||
import {View} from 'react-native';
|
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 Filter, {DIVIDERS_HEIGHT, FILTER_ITEM_HEIGHT, NUMBER_FILTER_ITEMS} from '@components/files/file_filter';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import {useIsTablet} from '@hooks/device';
|
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 {TITLE_SEPARATOR_MARGIN, TITLE_SEPARATOR_MARGIN_TABLET, TITLE_HEIGHT} from '@screens/bottom_sheet/content';
|
||||||
import TeamPicker from '@screens/home/search/team_picker';
|
import TeamPicker from '@screens/home/search/team_picker';
|
||||||
import {bottomSheet} from '@screens/navigation';
|
import {bottomSheet} from '@screens/navigation';
|
||||||
|
|
@ -17,12 +18,9 @@ import {bottomSheetSnapPoint} from '@utils/helpers';
|
||||||
import {TabTypes, type TabType} from '@utils/search';
|
import {TabTypes, type TabType} from '@utils/search';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
import SelectButton from './header_button';
|
|
||||||
|
|
||||||
import type TeamModel from '@typings/database/models/servers/team';
|
import type TeamModel from '@typings/database/models/servers/team';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onTabSelect: (tab: TabType) => void;
|
|
||||||
onFilterChanged: (filter: FileFilter) => void;
|
onFilterChanged: (filter: FileFilter) => void;
|
||||||
selectedTab: TabType;
|
selectedTab: TabType;
|
||||||
selectedFilter: FileFilter;
|
selectedFilter: FileFilter;
|
||||||
|
|
@ -30,6 +28,7 @@ type Props = {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
teams: TeamModel[];
|
teams: TeamModel[];
|
||||||
crossTeamSearchEnabled: boolean;
|
crossTeamSearchEnabled: boolean;
|
||||||
|
tabsProps: ComponentProps<typeof Tabs>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
|
|
@ -60,7 +59,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
},
|
},
|
||||||
filterContainer: {
|
filterContainer: {
|
||||||
flex: 1,
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|
@ -72,33 +70,23 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
const Header = ({
|
const Header = ({
|
||||||
teamId,
|
teamId,
|
||||||
setTeamId,
|
setTeamId,
|
||||||
onTabSelect,
|
|
||||||
onFilterChanged,
|
onFilterChanged,
|
||||||
selectedTab,
|
selectedTab,
|
||||||
selectedFilter,
|
selectedFilter,
|
||||||
teams,
|
teams,
|
||||||
crossTeamSearchEnabled,
|
crossTeamSearchEnabled,
|
||||||
|
tabsProps,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const styles = getStyleFromTheme(theme);
|
const styles = getStyleFromTheme(theme);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const isTablet = useIsTablet();
|
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 title = intl.formatMessage({id: 'screen.search.results.filter.title', defaultMessage: 'Filter by file type'});
|
||||||
|
|
||||||
const showFilterIcon = selectedTab === TabTypes.FILES;
|
const showFilterIcon = selectedTab === TabTypes.FILES;
|
||||||
const hasFilters = selectedFilter !== FileFilters.ALL;
|
const hasFilters = selectedFilter !== FileFilters.ALL;
|
||||||
|
|
||||||
const handleMessagesPress = useCallback(() => {
|
|
||||||
onTabSelect(TabTypes.MESSAGES);
|
|
||||||
}, [onTabSelect]);
|
|
||||||
|
|
||||||
const handleFilesPress = useCallback(() => {
|
|
||||||
onTabSelect(TabTypes.FILES);
|
|
||||||
}, [onTabSelect]);
|
|
||||||
|
|
||||||
const snapPoints = useMemo(() => {
|
const snapPoints = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
1,
|
1,
|
||||||
|
|
@ -131,48 +119,40 @@ const Header = ({
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.buttonContainer}>
|
<Tabs {...tabsProps}/>
|
||||||
<SelectButton
|
|
||||||
selected={selectedTab === TabTypes.MESSAGES}
|
|
||||||
onPress={handleMessagesPress}
|
|
||||||
text={messagesText}
|
|
||||||
/>
|
|
||||||
<SelectButton
|
|
||||||
selected={selectedTab === TabTypes.FILES}
|
|
||||||
onPress={handleFilesPress}
|
|
||||||
text={filesText}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{showFilterIcon && (
|
{showFilterIcon && (
|
||||||
<View style={styles.filterContainer}>
|
<View style={styles.filterContainer}>
|
||||||
<CompassIcon
|
<View>
|
||||||
name={'filter-variant'}
|
<CompassIcon
|
||||||
testID='search.filters.file_type_icon'
|
name={'filter-variant'}
|
||||||
size={24}
|
testID='search.filters.file_type_icon'
|
||||||
color={changeOpacity(
|
size={24}
|
||||||
theme.centerChannelColor,
|
color={changeOpacity(
|
||||||
0.56,
|
theme.centerChannelColor,
|
||||||
)}
|
0.56,
|
||||||
onPress={handleFilterPress}
|
)}
|
||||||
/>
|
onPress={handleFilterPress}
|
||||||
<Badge
|
/>
|
||||||
style={styles.badge}
|
<Badge
|
||||||
visible={hasFilters}
|
style={styles.badge}
|
||||||
testID={'search.filters.badge'}
|
visible={hasFilters}
|
||||||
value={-1}
|
testID={'search.filters.badge'}
|
||||||
/>
|
value={-1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View style={styles.teamPickerContainer}>
|
{teams.length > 1 && (
|
||||||
{teams.length > 1 && (
|
<View style={styles.teamPickerContainer}>
|
||||||
<TeamPicker
|
<TeamPicker
|
||||||
setTeamId={setTeamId}
|
setTeamId={setTeamId}
|
||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
teams={teams}
|
teams={teams}
|
||||||
crossTeamSearchEnabled={crossTeamSearchEnabled}
|
crossTeamSearchEnabled={crossTeamSearchEnabled}
|
||||||
/>
|
/>
|
||||||
)}
|
</View>
|
||||||
</View>
|
)}
|
||||||
|
|
||||||
</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 {addSearchToTeamSearchHistory} from '@actions/local/team';
|
||||||
import {searchPosts, searchFiles} from '@actions/remote/search';
|
import {searchPosts, searchFiles} from '@actions/remote/search';
|
||||||
|
import useTabs from '@hooks/use_tabs';
|
||||||
import {bottomSheet} from '@screens/navigation';
|
import {bottomSheet} from '@screens/navigation';
|
||||||
import {renderWithEverything} from '@test/intl-test-helper';
|
import {renderWithEverything} from '@test/intl-test-helper';
|
||||||
import TestHelper from '@test/test_helper';
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
@ -49,6 +50,11 @@ jest.mock('@screens/navigation', () => ({
|
||||||
bottomSheet: jest.fn(),
|
bottomSheet: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('@hooks/use_tabs', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(jest.requireActual('@hooks/use_tabs').default),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('SearchScreen', () => {
|
describe('SearchScreen', () => {
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
teamId: 'team1',
|
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 {useIsFocused, useNavigation} from '@react-navigation/native';
|
||||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
import {Freeze} from 'react-freeze';
|
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 {FlatList, type LayoutChangeEvent, Platform, type ViewStyle, KeyboardAvoidingView, Keyboard, StyleSheet} from 'react-native';
|
||||||
import Animated, {useAnimatedStyle, useDerivedValue, withTiming, type AnimatedStyle} from 'react-native-reanimated';
|
import Animated, {useAnimatedStyle, useDerivedValue, withTiming, type AnimatedStyle} from 'react-native-reanimated';
|
||||||
import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
|
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 {useKeyboardHeight} from '@hooks/device';
|
||||||
import useDidUpdate from '@hooks/did_update';
|
import useDidUpdate from '@hooks/did_update';
|
||||||
import {useCollapsibleHeader} from '@hooks/header';
|
import {useCollapsibleHeader} from '@hooks/header';
|
||||||
|
import useTabs from '@hooks/use_tabs';
|
||||||
import NavigationStore from '@store/navigation_store';
|
import NavigationStore from '@store/navigation_store';
|
||||||
import {type FileFilter, FileFilters, filterFileExtensions} from '@utils/file';
|
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 Initial from './initial';
|
||||||
import Results from './results';
|
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 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 SearchScreen = ({teamId, teams, crossTeamSearchEnabled}: Props) => {
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
|
|
@ -100,7 +116,6 @@ const SearchScreen = ({teamId, teams, crossTeamSearchEnabled}: Props) => {
|
||||||
const [cursorPosition, setCursorPosition] = useState(searchTerm?.length || 0);
|
const [cursorPosition, setCursorPosition] = useState(searchTerm?.length || 0);
|
||||||
const [searchValue, setSearchValue] = useState<string>(searchTerm || '');
|
const [searchValue, setSearchValue] = useState<string>(searchTerm || '');
|
||||||
const [searchTeamId, setSearchTeamId] = useState<string>(teamId);
|
const [searchTeamId, setSearchTeamId] = useState<string>(teamId);
|
||||||
const [selectedTab, setSelectedTab] = useState<TabType>(TabTypes.MESSAGES);
|
|
||||||
const [filter, setFilter] = useState<FileFilter>(FileFilters.ALL);
|
const [filter, setFilter] = useState<FileFilter>(FileFilters.ALL);
|
||||||
const [showResults, setShowResults] = useState(false);
|
const [showResults, setShowResults] = useState(false);
|
||||||
const [containerHeight, setContainerHeight] = useState(0);
|
const [containerHeight, setContainerHeight] = useState(0);
|
||||||
|
|
@ -114,6 +129,8 @@ const SearchScreen = ({teamId, teams, crossTeamSearchEnabled}: Props) => {
|
||||||
const [fileInfos, setFileInfos] = useState<FileInfo[]>(emptyFileResults);
|
const [fileInfos, setFileInfos] = useState<FileInfo[]>(emptyFileResults);
|
||||||
const [fileChannelIds, setFileChannelIds] = useState<string[]>([]);
|
const [fileChannelIds, setFileChannelIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [selectedTab, tabsProps] = useTabs(TabTypes.MESSAGES, tabs, undefined, 'search.tabs');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchTeamId(teamId);
|
setSearchTeamId(teamId);
|
||||||
}, [teamId]);
|
}, [teamId]);
|
||||||
|
|
@ -406,12 +423,12 @@ const SearchScreen = ({teamId, teams, crossTeamSearchEnabled}: Props) => {
|
||||||
<Header
|
<Header
|
||||||
teamId={searchTeamId}
|
teamId={searchTeamId}
|
||||||
setTeamId={handleResultsTeamChange}
|
setTeamId={handleResultsTeamChange}
|
||||||
onTabSelect={setSelectedTab}
|
|
||||||
onFilterChanged={handleFilterChange}
|
onFilterChanged={handleFilterChange}
|
||||||
selectedTab={selectedTab}
|
selectedTab={selectedTab}
|
||||||
selectedFilter={filter}
|
selectedFilter={filter}
|
||||||
teams={teams}
|
teams={teams}
|
||||||
crossTeamSearchEnabled={crossTeamSearchEnabled}
|
crossTeamSearchEnabled={crossTeamSearchEnabled}
|
||||||
|
tabsProps={tabsProps}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ class GlobalThreadsScreen {
|
||||||
testID = {
|
testID = {
|
||||||
threadItemPrefix: 'global_threads.threads_list.thread_item.',
|
threadItemPrefix: 'global_threads.threads_list.thread_item.',
|
||||||
globalThreadsScreen: 'global_threads.screen',
|
globalThreadsScreen: 'global_threads.screen',
|
||||||
headerAllThreadsButton: 'global_threads.threads_list.header.all_threads.button',
|
headerAllThreadsButton: 'global_threads.threads_list.header.all.button',
|
||||||
headerUnreadThreadsButton: 'global_threads.threads_list.header.unread_threads.button',
|
headerUnreadThreadsButton: 'global_threads.threads_list.header.unreads.button',
|
||||||
headerUnreadDotBadge: 'global_threads.threads_list.header.unread_dot.badge',
|
headerUnreadDotBadge: 'global_threads.threads_list.header.unreads.badge',
|
||||||
headerMarkAllAsReadButton: 'global_threads.threads_list.header.mark_all_as_read.button',
|
headerMarkAllAsReadButton: 'global_threads.threads_list.header.mark_all_as_read.button',
|
||||||
emptyThreadsList: 'global_threads.threads_list.empty_state',
|
emptyThreadsList: 'global_threads.threads_list.empty_state',
|
||||||
flatThreadsList: 'global_threads.threads_list.flat_list',
|
flatThreadsList: 'global_threads.threads_list.flat_list',
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ class ScheduledMessageScreen {
|
||||||
customDateTimePickerScreen: 'custom_date_time_picker',
|
customDateTimePickerScreen: 'custom_date_time_picker',
|
||||||
deleteDraft: 'delete_draft',
|
deleteDraft: 'delete_draft',
|
||||||
rescheduleOption: 'rescheduled_draft',
|
rescheduleOption: 'rescheduled_draft',
|
||||||
scheduledTab: 'scheduled_post_tab',
|
scheduledTab: 'tabs.scheduled_posts.button',
|
||||||
scheduledTabBadgeCount: 'scheduled_post_count_badge',
|
scheduledTabBadgeCount: 'tabs.scheduled_posts.badge',
|
||||||
scheduledMessageTooltipCloseButton: 'draft.tooltip.close.button',
|
scheduledMessageTooltipCloseButton: 'draft.tooltip.close.button',
|
||||||
scheduledMessageText: 'markdown_paragraph',
|
scheduledMessageText: 'markdown_paragraph',
|
||||||
scheduledDraftTime: 'scheduled_post_header.scheduled_at',
|
scheduledDraftTime: 'scheduled_post_header.scheduled_at',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue