MM-59065 - New channel menu using new menu system (#30093)

* New channel menu using new menu system

* fix e2e-tests

* remove extraneous separator

* lint fix

* fix test after merge

* update to pass properties to first menu item

* fix e2etest

* refactor: Update channel header menu items to use const event handlers

* refactor: Extract plugin item click handler in channel header menu

* refactor: Improve error handling and button click handlers in mobile channel header plugins

* lint fixes

* updates for code reveiw

* run i18n-extract

* fix unit test

* fix: Close channel dropdown menu by clicking channel header title

* fix: Use keyboard escape to close channel dropdown menu in e2e tests

* fix cypress test

* fix: Resolve MUI Menu component fragment rendering issue

* cleanup

* remove unneccessary css

* fixing testing issues

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Scott Bishel
2025-03-19 07:06:04 -05:00
коммит произвёл GitHub
родитель 8eadf849bb
Коммит fd717cfa64
133 изменённых файлов: 4279 добавлений и 6211 удалений

Просмотреть файл

@@ -1,75 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Purpose of this file to exists is only required until channel header dropdown is migrated to new menus
import type {ComponentProps} from 'react';
import React, {memo} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {
LinkVariantIcon,
PaperclipIcon,
} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {getChannelBookmarks} from 'mattermost-redux/selectors/entities/channel_bookmarks';
import {useBookmarkAddActions} from 'components/channel_bookmarks/channel_bookmarks_menu';
import {MAX_BOOKMARKS_PER_CHANNEL, useCanUploadFiles, useChannelBookmarkPermission} from 'components/channel_bookmarks/utils';
import Menu from 'components/widgets/menu/menu';
import type {GlobalState} from 'types/store';
type Props = {
channel: Channel;
inHeaderDropdown?: boolean;
};
const ChannelBookmarksSubmenu = (props: Props) => {
const {formatMessage} = useIntl();
const {handleCreateLink, handleCreateFile} = useBookmarkAddActions(props.channel.id);
const canAdd = useChannelBookmarkPermission(props.channel.id, 'add');
const canUploadFiles = useCanUploadFiles();
const limitReached = useSelector((state: GlobalState) => {
const bookmarks = getChannelBookmarks(state, props.channel.id);
return bookmarks && Object.keys(bookmarks).length >= MAX_BOOKMARKS_PER_CHANNEL;
});
if (!canAdd || limitReached) {
return null;
}
const items: ComponentProps<typeof Menu.ItemSubMenu>['subMenu'] = [
{
id: 'channelBookmarksAddLink',
icon: <LinkVariantIcon size={16}/>,
direction: 'right',
text: formatMessage({id: 'channel_bookmarks.addLink', defaultMessage: 'Add a link'}),
action: handleCreateLink,
},
];
if (canUploadFiles) {
items.push({
id: 'channelBookmarksAttachFile',
icon: <PaperclipIcon size={16}/>,
direction: 'right',
text: formatMessage({id: 'channel_bookmarks.attachFile', defaultMessage: 'Attach a file'}),
action: handleCreateFile,
});
}
return (
<Menu.ItemSubMenu
id={`channel-menu-${props.channel.id}-bookmarks`}
subMenu={items}
text={formatMessage({id: 'sidebar_left.sidebar_channel_menu.bookmarks', defaultMessage: 'Bookmarks Bar'})}
direction={'right'}
styleSelectableItem={true}
/>
);
};
export default memo(ChannelBookmarksSubmenu);

Просмотреть файл

@@ -1,27 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import type {ReactNode} from 'react';
import React, {memo, useState, useRef, useEffect} from 'react';
import {useIntl} from 'react-intl';
import React, {memo} from 'react';
import {useSelector} from 'react-redux';
import type {UserProfile} from '@mattermost/types/users';
import {Client4} from 'mattermost-redux/client';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {getIsRhsOpen} from 'selectors/rhs';
import {ChannelHeaderDropdown} from 'components/channel_header_dropdown';
import ProfilePicture from 'components/profile_picture';
import SharedChannelIndicator from 'components/shared_channel_indicator';
import ArchiveIcon from 'components/widgets/icons/archive_icon';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import BotTag from 'components/widgets/tag/bot_tag';
import WithTooltip from 'components/with_tooltip';
import {Constants} from 'utils/constants';
@@ -29,6 +21,8 @@ import ChannelHeaderTitleDirect from './channel_header_title_direct';
import ChannelHeaderTitleFavorite from './channel_header_title_favorite';
import ChannelHeaderTitleGroup from './channel_header_title_group';
import ChannelHeaderMenu from '../channel_header_menu/channel_header_menu';
type Props = {
dmUser?: UserProfile;
gmMembers?: UserProfile[];
@@ -38,45 +32,22 @@ const ChannelHeaderTitle = ({
dmUser,
gmMembers,
}: Props) => {
const [titleMenuOpen, setTitleMenuOpen] = useState(false);
const [showTooltip, setShowTooltip] = useState(false);
const intl = useIntl();
const channel = useSelector(getCurrentChannel);
const currentUser = useSelector(getCurrentUser);
const headerItemRef = useRef<HTMLElement | null>(null);
const isRHSOpen = useSelector(getIsRhsOpen);
useEffect(() => {
enableToolTipIfNeeded();
// Re-check on window resize
const handleResize = () => enableToolTipIfNeeded();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [channel, gmMembers, dmUser, isRHSOpen]);
if (!channel) {
return null;
}
const enableToolTipIfNeeded = () => {
const element = headerItemRef.current;
const isTooltip = element && element.offsetWidth < element.scrollWidth;
setShowTooltip(isTooltip as boolean);
};
const isDirect = (channel.type === Constants.DM_CHANNEL);
const isGroup = (channel.type === Constants.GM_CHANNEL);
const channelIsArchived = channel.delete_at !== 0;
let archivedIcon: React.ReactNode = null;
let archivedIcon;
if (channelIsArchived) {
archivedIcon = <ArchiveIcon className='icon icon__archive icon channel-header-archived-icon svg-text-color'/>;
}
let sharedIcon = null;
let sharedIcon;
if (channel.shared) {
sharedIcon = (
<SharedChannelIndicator
@@ -118,20 +89,6 @@ const ChannelHeaderTitle = ({
);
}
const personalChannelHeaderAriaLabel = intl.formatMessage({
id: 'channel_header.directchannel',
defaultMessage: '{displayName} (you) Channel Menu',
}, {
displayName: channel.display_name,
});
const othersChannelHeaderAriaLabel = intl.formatMessage({
id: 'channel_header.otherchannel',
defaultMessage: '{displayName} Channel Menu',
}, {
displayName: channel.display_name,
});
return (
<div className='channel-header__top'>
<ChannelHeaderTitleFavorite/>
@@ -142,60 +99,12 @@ const ChannelHeaderTitle = ({
status={channel.status}
/>
)}
<MenuWrapper onToggle={setTitleMenuOpen}>
<div
id='channelHeaderDropdownButton'
>
<button
id='channelHeaderMenuButton'
className={classNames('channel-header__trigger style--none', {active: titleMenuOpen})}
aria-label={
(isDirect && currentUser.id === dmUser?.id) ? personalChannelHeaderAriaLabel.toLowerCase() : othersChannelHeaderAriaLabel.toLowerCase()
}
aria-expanded={titleMenuOpen}
aria-controls='channelHeaderDropdownMenu'
>
{showTooltip ? (
<WithTooltip
title={channelTitle as string}
>
<strong
id='channelHeaderTitle'
className='heading'
ref={headerItemRef}
>
<span>
{archivedIcon}
{channelTitle}
{sharedIcon}
</span>
</strong>
</WithTooltip>
) : (
<strong
id='channelHeaderTitle'
className='heading'
ref={headerItemRef}
>
<span>
{archivedIcon}
{channelTitle}
{sharedIcon}
</span>
</strong>
)}
<span
id='channelHeaderDropdownIcon'
className='icon icon-chevron-down header-dropdown-chevron-icon'
aria-hidden='true'
/>
</button>
</div>
<ChannelHeaderDropdown
ariaLabel={
(isDirect && currentUser.id === dmUser?.id) ? personalChannelHeaderAriaLabel.toLowerCase() : othersChannelHeaderAriaLabel.toLowerCase()}
/>
</MenuWrapper>
<ChannelHeaderMenu
dmUser={dmUser}
gmMembers={gmMembers}
sharedIcon={sharedIcon}
archivedIcon={archivedIcon}
/>
</div>
);
};

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -1,43 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import {TestHelper} from 'utils/test_helper';
import ChannelHeaderDropdown from './channel_header_dropdown_items';
import type {Props} from './channel_header_dropdown_items';
describe('components/ChannelHeaderDropdown', () => {
const defaultProps = {
user: TestHelper.getUserMock({id: 'test-user-id'}),
channel: TestHelper.getChannelMock({id: 'test-channel-id'}),
isDefault: false,
isFavorite: false,
isReadonly: false,
isMuted: false,
isArchived: false,
isMobile: false,
penultimateViewedChannelName: 'test-channel',
pluginMenuItems: [],
isLicensedForLDAPGroups: false,
isChannelBookmarksEnabled: false,
};
test('should match snapshot with no plugin items', () => {
const wrapper = shallow(<ChannelHeaderDropdown {...defaultProps}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with plugins', () => {
const props: Props = {
...defaultProps,
pluginMenuItems: [
{id: 'plugin-1', pluginId: 'playbooks', action: jest.fn(), text: 'plugin-1-text', shouldRender: () => true},
{id: 'plugin-2', pluginId: 'playbooks', action: jest.fn(), text: 'plugin-2-text', shouldRender: () => true},
],
};
const wrapper = shallow(<ChannelHeaderDropdown {...props}/>);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -1,21 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {ChannelHeaderDropdownItems} from 'components/channel_header_dropdown';
import Menu from 'components/widgets/menu/menu';
const ChannelHeaderDropdown = ({ariaLabel}: {
ariaLabel: string;
}) =>
(
<Menu
id='channelHeaderDropdownMenu'
ariaLabel={ariaLabel}
>
<ChannelHeaderDropdownItems isMobile={false}/>
</Menu>
);
export default memo(ChannelHeaderDropdown);

Просмотреть файл

@@ -1,355 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import {Permissions} from 'mattermost-redux/constants';
import {isGuest} from 'mattermost-redux/utils/user_utils';
import AddGroupsToChannelModal from 'components/add_groups_to_channel_modal';
import ChannelBookmarksSubmenu from 'components/channel_bookmarks_sub_menu';
import ChannelGroupsManageModal from 'components/channel_groups_manage_modal';
import ChannelInviteModal from 'components/channel_invite_modal';
import ChannelMoveToSubMenuOld from 'components/channel_move_to_sub_menu_old';
import ChannelNotificationsModal from 'components/channel_notifications_modal';
import ConvertChannelModal from 'components/convert_channel_modal';
import ConvertGmToChannelModal from 'components/convert_gm_to_channel_modal';
import DeleteChannelModal from 'components/delete_channel_modal';
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
import EditChannelPurposeModal from 'components/edit_channel_purpose_modal';
import MoreDirectChannels from 'components/more_direct_channels';
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
import RenameChannelModal from 'components/rename_channel_modal';
import UnarchiveChannelModal from 'components/unarchive_channel_modal';
import Menu from 'components/widgets/menu/menu';
import MobileChannelHeaderPlug from 'plugins/mobile_channel_header_plug';
import {Constants, ModalIdentifiers} from 'utils/constants';
import {localizeMessage} from 'utils/utils';
import type {ChannelHeaderAction} from 'types/store/plugins';
import MenuItemCloseChannel from './menu_items/close_channel';
import MenuItemCloseMessage from './menu_items/close_message';
import MenuItemLeaveChannel from './menu_items/leave_channel';
import MenuItemOpenMembersRHS from './menu_items/open_members_rhs';
import MenuItemToggleFavoriteChannel from './menu_items/toggle_favorite_channel';
import MenuItemToggleInfo from './menu_items/toggle_info';
import MenuItemToggleMuteChannel from './menu_items/toggle_mute_channel';
import MenuItemViewPinnedPosts from './menu_items/view_pinned_posts';
export type Props = {
user: UserProfile;
channel?: Channel;
isDefault: boolean;
isFavorite: boolean;
isReadonly: boolean;
isMuted: boolean;
isArchived: boolean;
isMobile: boolean;
penultimateViewedChannelName: string;
pluginMenuItems: ChannelHeaderAction[];
isLicensedForLDAPGroups: boolean;
isChannelBookmarksEnabled: boolean;
}
export default class ChannelHeaderDropdown extends React.PureComponent<Props> {
render() {
const {
user,
channel,
isDefault,
isFavorite,
isMuted,
isReadonly,
isArchived,
isMobile,
penultimateViewedChannelName,
isLicensedForLDAPGroups,
isChannelBookmarksEnabled,
} = this.props;
if (!channel) {
return null;
}
const isPrivate = channel.type === Constants.PRIVATE_CHANNEL;
const isGroupConstrained = channel.group_constrained === true;
const channelMembersPermission = isPrivate ? Permissions.MANAGE_PRIVATE_CHANNEL_MEMBERS : Permissions.MANAGE_PUBLIC_CHANNEL_MEMBERS;
const channelPropertiesPermission = isPrivate ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES;
const channelDeletePermission = isPrivate ? Permissions.DELETE_PRIVATE_CHANNEL : Permissions.DELETE_PUBLIC_CHANNEL;
const channelUnarchivePermission = Permissions.MANAGE_TEAM;
let divider;
if (isMobile) {
divider = (
<li className='MenuGroup mobile-menu-divider'>
<hr/>
</li>
);
}
const pluginItems = this.props.pluginMenuItems.map((item) => {
return (
<Menu.ItemAction
id={item.id + '_pluginmenuitem'}
key={item.id + '_pluginmenuitem'}
onClick={() => {
if (item.action) {
item.action(channel.id);
}
}}
text={item.text}
/>
);
});
return (
<>
<MenuItemToggleInfo
show={channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL}
channel={channel}
/>
{/* Remove when this components is migrated to new menus */}
<ChannelMoveToSubMenuOld
channel={channel}
openUp={false}
inHeaderDropdown={true}
/>
<Menu.Group divider={divider}>
<MenuItemToggleFavoriteChannel
show={isMobile}
channel={channel}
isFavorite={isFavorite}
/>
<MenuItemViewPinnedPosts
show={isMobile}
channel={channel}
/>
<Menu.ItemToggleModalRedux
id='channelNotificationPreferences'
show={channel.type !== Constants.DM_CHANNEL && !isArchived}
modalId={ModalIdentifiers.CHANNEL_NOTIFICATIONS}
dialogType={ChannelNotificationsModal}
dialogProps={{
channel,
currentUser: user,
focusOriginElement: 'channelHeaderMenuButton',
}}
text={localizeMessage({id: 'navbar.preferences', defaultMessage: 'Notification Preferences'})}
/>
<MenuItemToggleMuteChannel
id='channelToggleMuteChannel'
user={user}
channel={channel}
isMuted={isMuted}
/>
</Menu.Group>
<Menu.Group divider={divider}>
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelMembersPermission]}
>
<Menu.ItemToggleModalRedux
id='channelInviteMembers'
show={channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL && !isArchived && !isDefault && !isGroupConstrained}
modalId={ModalIdentifiers.CHANNEL_INVITE}
dialogType={ChannelInviteModal}
dialogProps={{channel}}
text={localizeMessage({id: 'navbar.addMembers', defaultMessage: 'Add Members'})}
/>
<Menu.ItemToggleModalRedux
id='channelAddMembers'
show={channel.type === Constants.GM_CHANNEL && !isArchived && !isGroupConstrained}
modalId={ModalIdentifiers.CREATE_DM_CHANNEL}
dialogType={MoreDirectChannels}
dialogProps={{isExistingChannel: true, focusOriginElement: 'channelHeaderMenuButton'}}
text={localizeMessage({id: 'navbar.addMembers', defaultMessage: 'Add Members'})}
/>
</ChannelPermissionGate>
<MenuItemOpenMembersRHS
id='channelViewMembers'
channel={channel}
show={channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL && (isArchived || isDefault)}
text={localizeMessage({id: 'channel_header.viewMembers', defaultMessage: 'View Members'})}
/>
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelMembersPermission]}
>
<Menu.ItemToggleModalRedux
id='channelAddGroups'
show={channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL && !isArchived && !isDefault && isGroupConstrained && isLicensedForLDAPGroups}
modalId={ModalIdentifiers.ADD_GROUPS_TO_CHANNEL}
dialogType={AddGroupsToChannelModal}
text={localizeMessage({id: 'navbar.addGroups', defaultMessage: 'Add Groups'})}
/>
<Menu.ItemToggleModalRedux
id='channelManageGroups'
show={channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL && !isArchived && !isDefault && isGroupConstrained && isLicensedForLDAPGroups}
modalId={ModalIdentifiers.MANAGE_CHANNEL_GROUPS}
dialogType={ChannelGroupsManageModal}
dialogProps={{channelID: channel.id}}
text={localizeMessage({id: 'navbar_dropdown.manageGroups', defaultMessage: 'Manage Groups'})}
/>
<MenuItemOpenMembersRHS
id='channelManageMembers'
channel={channel}
show={channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL && !isArchived && !isDefault}
text={localizeMessage({id: 'channel_header.manageMembers', defaultMessage: 'Manage Members'})}
editMembers={!isArchived}
/>
</ChannelPermissionGate>
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelMembersPermission]}
invert={true}
>
<MenuItemOpenMembersRHS
id='channelViewMembers'
channel={channel}
show={channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL && !isArchived && !isDefault}
text={localizeMessage({id: 'channel_header.viewMembers', defaultMessage: 'View Members'})}
/>
</ChannelPermissionGate>
</Menu.Group>
<Menu.Group divider={divider}>
<Menu.ItemToggleModalRedux
id='channelEditHeader'
show={(channel.type === Constants.DM_CHANNEL || channel.type === Constants.GM_CHANNEL) && !isArchived && !isReadonly}
modalId={ModalIdentifiers.EDIT_CHANNEL_HEADER}
dialogType={EditChannelHeaderModal}
dialogProps={{channel}}
text={localizeMessage({id: 'channel_header.setConversationHeader', defaultMessage: 'Edit Conversation Header'})}
/>
<Menu.ItemToggleModalRedux
id='convertGMPrivateChannel'
show={channel.type === Constants.GM_CHANNEL && !isArchived && !isReadonly && !isGuest(user.roles)}
modalId={ModalIdentifiers.CONVERT_GM_TO_CHANNEL}
dialogType={ConvertGmToChannelModal}
dialogProps={{channel}}
text={localizeMessage({id: 'sidebar_left.sidebar_channel_menu_convert_to_channel', defaultMessage: 'Convert to Private Channel'})}
/>
</Menu.Group>
<Menu.Group divider={divider}>
{isChannelBookmarksEnabled && <ChannelBookmarksSubmenu channel={channel}/>}
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelPropertiesPermission]}
>
<Menu.ItemToggleModalRedux
id='channelEditHeader'
show={channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL && !isArchived && !isReadonly}
modalId={ModalIdentifiers.EDIT_CHANNEL_HEADER}
dialogType={EditChannelHeaderModal}
dialogProps={{channel}}
text={localizeMessage({id: 'channel_header.setHeader', defaultMessage: 'Edit Channel Header'})}
/>
<Menu.ItemToggleModalRedux
id='channelEditPurpose'
show={!isArchived && !isReadonly && channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL}
modalId={ModalIdentifiers.EDIT_CHANNEL_PURPOSE}
dialogType={EditChannelPurposeModal}
dialogProps={{channel}}
text={localizeMessage({id: 'channel_header.setPurpose', defaultMessage: 'Edit Channel Purpose'})}
/>
<Menu.ItemToggleModalRedux
id='channelRename'
show={!isArchived && channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL}
modalId={ModalIdentifiers.RENAME_CHANNEL}
dialogType={RenameChannelModal}
dialogProps={{channel}}
text={localizeMessage({id: 'channel_header.rename', defaultMessage: 'Rename Channel'})}
/>
</ChannelPermissionGate>
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE]}
>
<Menu.ItemToggleModalRedux
id='channelConvertToPrivate'
show={!isArchived && !isDefault && channel.type === Constants.OPEN_CHANNEL}
modalId={ModalIdentifiers.CONVERT_CHANNEL}
dialogType={ConvertChannelModal}
dialogProps={{
channelId: channel.id,
channelDisplayName: channel.display_name,
}}
text={localizeMessage({id: 'channel_header.convert', defaultMessage: 'Convert to Private Channel'})}
/>
</ChannelPermissionGate>
<MenuItemLeaveChannel
id='channelLeaveChannel'
channel={channel}
isDefault={isDefault}
isGuestUser={isGuest(user.roles)}
/>
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelDeletePermission]}
>
<Menu.ItemToggleModalRedux
id='channelArchiveChannel'
show={!isArchived && !isDefault && channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL}
modalId={ModalIdentifiers.DELETE_CHANNEL}
className='MenuItem__dangerous'
dialogType={DeleteChannelModal}
dialogProps={{
channel,
penultimateViewedChannelName,
}}
text={localizeMessage({id: 'channel_header.delete', defaultMessage: 'Archive Channel'})}
/>
</ChannelPermissionGate>
{isMobile &&
<MobileChannelHeaderPlug
channel={channel}
isDropdown={true}
/>}
<MenuItemCloseMessage
id='channelCloseMessage'
channel={channel}
currentUser={user}
/>
<MenuItemCloseChannel
isArchived={isArchived}
/>
</Menu.Group>
<Menu.Group>
{pluginItems}
</Menu.Group>
<Menu.Group divider={divider}>
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelUnarchivePermission]}
>
<Menu.ItemToggleModalRedux
id='channelUnarchiveChannel'
show={isArchived && !isDefault && channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL}
modalId={ModalIdentifiers.UNARCHIVE_CHANNEL}
dialogType={UnarchiveChannelModal}
dialogProps={{
channel,
}}
text={localizeMessage({id: 'channel_header.unarchive', defaultMessage: 'Unarchive Channel'})}
/>
</ChannelPermissionGate>
</Menu.Group>
</>
);
}
}

Просмотреть файл

@@ -1,102 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {createSelector} from 'mattermost-redux/selectors/create_selector';
import {
getCurrentChannel,
isCurrentChannelDefault,
isCurrentChannelFavorite,
isCurrentChannelMuted,
isCurrentChannelArchived,
getRedirectChannelNameForTeam,
} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {
getUser,
getCurrentUser,
getUserStatuses,
getCurrentUserId,
} from 'mattermost-redux/selectors/entities/users';
import {getPenultimateViewedChannelName} from 'selectors/local_storage';
import {getChannelHeaderMenuPluginComponents} from 'selectors/plugins';
import {getIsChannelBookmarksEnabled} from 'components/channel_bookmarks/utils';
import {Constants} from 'utils/constants';
import * as Utils from 'utils/utils';
import type {GlobalState} from 'types/store';
import Desktop from './channel_header_dropdown';
import Items from './channel_header_dropdown_items';
import Mobile from './mobile_channel_header_dropdown';
const getTeammateId = createSelector(
'getTeammateId',
getCurrentChannel,
getCurrentUserId,
(channel, currentUserId) => {
if (channel?.type !== Constants.DM_CHANNEL) {
return null;
}
return Utils.getUserIdFromChannelId(channel.name, currentUserId);
},
);
const getTeammateStatus = createSelector(
'getTeammateStatus',
getUserStatuses,
getTeammateId,
(userStatuses, teammateId) => {
if (!teammateId) {
return undefined;
}
return userStatuses[teammateId];
},
);
const mapStateToProps = (state: GlobalState) => ({
user: getCurrentUser(state),
channel: getCurrentChannel(state),
isDefault: isCurrentChannelDefault(state),
isFavorite: isCurrentChannelFavorite(state),
isMuted: isCurrentChannelMuted(state),
isReadonly: false,
isArchived: isCurrentChannelArchived(state),
penultimateViewedChannelName: getPenultimateViewedChannelName(state) || getRedirectChannelNameForTeam(state, getCurrentTeamId(state)),
pluginMenuItems: getChannelHeaderMenuPluginComponents(state),
isLicensedForLDAPGroups: state.entities.general.license.LDAPGroups === 'true',
isChannelBookmarksEnabled: getIsChannelBookmarksEnabled(state),
});
const mobileMapStateToProps = (state: GlobalState) => {
const user = getCurrentUser(state);
const channel = getCurrentChannel(state);
const teammateId = getTeammateId(state);
let teammateIsBot = false;
let displayName = '';
if (teammateId) {
const teammate = getUser(state, teammateId);
teammateIsBot = teammate && teammate.is_bot;
displayName = Utils.getDisplayNameByUser(state, teammate);
}
return {
user,
channel,
teammateId,
teammateIsBot,
teammateStatus: getTeammateStatus(state),
displayName,
};
};
export const ChannelHeaderDropdown = Desktop;
export const ChannelHeaderDropdownItems = connect(mapStateToProps)(Items);
export const MobileChannelHeaderDropdown = connect(mobileMapStateToProps)(Mobile);

Просмотреть файл

@@ -1,17 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/ChannelHeaderDropdown/MenuItem.CloseChannel shoud be hidden if the channel is not archived 1`] = `
<MenuItemAction
onClick={[MockFunction]}
show={false}
text="Close Channel"
/>
`;
exports[`components/ChannelHeaderDropdown/MenuItem.CloseChannel should match snapshot 1`] = `
<MenuItemAction
onClick={[MockFunction]}
show={true}
text="Close Channel"
/>
`;

Просмотреть файл

@@ -1,45 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import Menu from 'components/widgets/menu/menu';
import CloseChannel from './close_channel';
describe('components/ChannelHeaderDropdown/MenuItem.CloseChannel', () => {
const baseProps = {
isArchived: true,
actions: {
goToLastViewedChannel: jest.fn(),
},
};
it('should match snapshot', () => {
const wrapper = shallow(<CloseChannel {...baseProps}/>);
expect(wrapper).toMatchSnapshot();
});
it('shoud be hidden if the channel is not archived', () => {
const props = {
...baseProps,
isArchived: false,
};
const wrapper = shallow(<CloseChannel {...props}/>);
expect(wrapper).toMatchSnapshot();
});
it('should runs goToLastViewedChannel function on click', () => {
const props = {
...baseProps,
actions: {
...baseProps.actions,
goToLastViewedChannel: jest.fn(),
},
};
const wrapper = shallow(<CloseChannel {...props}/>);
wrapper.find(Menu.ItemAction).simulate('click');
expect(props.actions.goToLastViewedChannel).toHaveBeenCalled();
});
});

Просмотреть файл

@@ -1,34 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import Menu from 'components/widgets/menu/menu';
type Props = {
isArchived: boolean;
actions: {
goToLastViewedChannel: () => void;
};
}
const CloseChannel = ({
isArchived,
actions,
}: Props): JSX.Element => {
const intl = useIntl();
return (
<Menu.ItemAction
show={isArchived}
onClick={actions.goToLastViewedChannel}
text={intl.formatMessage({
id: 'center_panel.archived.closeChannel',
defaultMessage: 'Close Channel',
})}
/>
);
};
export default React.memo(CloseChannel);

Просмотреть файл

@@ -1,18 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {goToLastViewedChannel} from 'actions/views/channel';
import CloseChannel from './close_channel';
const mapDispatchToProps = (dispatch: Dispatch) => ({
actions: bindActionCreators({
goToLastViewedChannel,
}, dispatch),
});
export default connect(null, mapDispatchToProps)(CloseChannel);

Просмотреть файл

@@ -1,17 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/ChannelHeaderDropdown/MenuItem.CloseMessage should match snapshot for DM Channel 1`] = `
<MenuItemAction
onClick={[Function]}
show={true}
text="Close Direct Message"
/>
`;
exports[`components/ChannelHeaderDropdown/MenuItem.CloseMessage should match snapshot for GM Channel 1`] = `
<MenuItemAction
onClick={[Function]}
show={true}
text="Close Group Message"
/>
`;

Просмотреть файл

@@ -1,74 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import type {ChannelType} from '@mattermost/types/channels';
import type {TeamType} from '@mattermost/types/teams';
import Menu from 'components/widgets/menu/menu';
import {Constants} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import CloseMessage from './close_message';
describe('components/ChannelHeaderDropdown/MenuItem.CloseMessage', () => {
const baseProps = {
currentUser: TestHelper.getUserMock(),
redirectChannel: 'test-default-channel',
currentTeam: TestHelper.getTeamMock({
id: 'team_id',
name: 'test-team',
display_name: 'Test team display name',
description: 'Test team description',
type: 'team-type' as TeamType,
}),
actions: {
savePreferences: jest.fn(() => Promise.resolve()),
leaveDirectChannel: jest.fn(() => Promise.resolve()),
},
};
const groupChannel = TestHelper.getChannelMock({
id: 'channel_id',
type: Constants.GM_CHANNEL as ChannelType,
});
const directChannel = TestHelper.getChannelMock({
id: 'channel_id',
type: Constants.DM_CHANNEL as ChannelType,
teammate_id: 'teammate-id',
});
it('should match snapshot for DM Channel', () => {
const props = {...baseProps, channel: directChannel};
const wrapper = shallow(<CloseMessage {...props}/>);
expect(wrapper).toMatchSnapshot();
});
it('should match snapshot for GM Channel', () => {
const props = {...baseProps, channel: groupChannel};
const wrapper = shallow(<CloseMessage {...props}/>);
expect(wrapper).toMatchSnapshot();
});
it('should run savePreferences function on click for DM', () => {
const props = {...baseProps, channel: directChannel};
const wrapper = shallow(<CloseMessage {...props}/>);
wrapper.find(Menu.ItemAction).simulate('click', {
preventDefault: jest.fn(),
});
expect(props.actions.savePreferences).toBeCalledWith(props.currentUser.id, [{user_id: props.currentUser.id, category: Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, name: props.channel.teammate_id, value: 'false'}]);
});
it('should run savePreferences function on click for GM', () => {
const props = {...baseProps, channel: groupChannel};
const wrapper = shallow(<CloseMessage {...props}/>);
wrapper.find(Menu.ItemAction).simulate('click', {
preventDefault: jest.fn(),
});
expect(props.actions.savePreferences).toBeCalledWith(props.currentUser.id, [{user_id: props.currentUser.id, category: Constants.Preferences.CATEGORY_GROUP_CHANNEL_SHOW, name: props.channel.id, value: 'false'}]);
});
});

Просмотреть файл

@@ -1,113 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import Menu from 'components/widgets/menu/menu';
import {getHistory} from 'utils/browser_history';
import {Constants} from 'utils/constants';
import {localizeMessage} from 'utils/utils';
type Props = {
/**
* Object with info about currentUser
*/
currentUser: UserProfile;
/**
* Object with info about currentTeam
*/
currentTeam?: Team;
/**
* String with info about redirect
*/
redirectChannel: string;
/**
* Object with info about channel
*/
channel: Channel;
/**
* Use for test selector
*/
id?: string;
/**
* Object with action creators
*/
actions: {
/**
* Action creator to update user preferences
*/
savePreferences: (userId: string, preferences: PreferenceType[]) => void;
/**
* Action creator to leave DM/GM
*/
leaveDirectChannel: (channelName: string) => void;
};
};
export default class CloseMessage extends React.PureComponent<Props> {
handleClose = (e: React.MouseEvent): void => {
e.preventDefault();
const {
channel,
currentUser,
currentTeam,
redirectChannel,
actions: {
savePreferences,
leaveDirectChannel,
},
} = this.props;
let name: string;
let category;
if (channel.type === Constants.DM_CHANNEL) {
category = Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW;
name = channel.teammate_id!;
} else {
category = Constants.Preferences.CATEGORY_GROUP_CHANNEL_SHOW;
name = channel.id;
}
leaveDirectChannel(channel.name);
savePreferences(currentUser.id, [{user_id: currentUser.id, category, name, value: 'false'}]);
if (currentTeam) {
getHistory().push(`/${currentTeam.name}/channels/${redirectChannel}`);
}
};
render(): React.ReactNode {
const {id, channel} = this.props;
let text;
if (channel.type === Constants.DM_CHANNEL) {
text = localizeMessage({id: 'center_panel.direct.closeDirectMessage', defaultMessage: 'Close Direct Message'});
} else if (channel.type === Constants.GM_CHANNEL) {
text = localizeMessage({id: 'center_panel.direct.closeGroupMessage', defaultMessage: 'Close Group Message'});
}
return (
<Menu.ItemAction
id={id}
show={channel.type === Constants.DM_CHANNEL || channel.type === Constants.GM_CHANNEL}
onClick={this.handleClose}
text={text}
/>
);
}
}

Просмотреть файл

@@ -1,29 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getRedirectChannelNameForTeam} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeam, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {leaveDirectChannel} from 'actions/views/channel';
import type {GlobalState} from 'types/store';
import CloseMessage from './close_message';
const mapStateToProps = (state: GlobalState) => {
return {
currentTeam: getCurrentTeam(state),
redirectChannel: getRedirectChannelNameForTeam(state, getCurrentTeamId(state)),
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({
actions: bindActionCreators({savePreferences, leaveDirectChannel}, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(CloseMessage);

Просмотреть файл

@@ -1,37 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/ChannelHeaderDropdown/MenuItem.LeaveChannel should be hidden if the channel is default channel 1`] = `
<MenuItemAction
isDangerous={true}
onClick={[Function]}
show={false}
text="Leave Channel"
/>
`;
exports[`components/ChannelHeaderDropdown/MenuItem.LeaveChannel should be hidden if the channel type is DM or GM 1`] = `
<MenuItemAction
isDangerous={true}
onClick={[Function]}
show={false}
text="Leave Channel"
/>
`;
exports[`components/ChannelHeaderDropdown/MenuItem.LeaveChannel should be hidden if the channel type is DM or GM 2`] = `
<MenuItemAction
isDangerous={true}
onClick={[Function]}
show={false}
text="Leave Channel"
/>
`;
exports[`components/ChannelHeaderDropdown/MenuItem.LeaveChannel should match snapshot 1`] = `
<MenuItemAction
isDangerous={true}
onClick={[Function]}
show={true}
text="Leave Channel"
/>
`;

Просмотреть файл

@@ -1,27 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import type {ConnectedProps} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {leaveChannel} from 'actions/views/channel';
import {openModal} from 'actions/views/modals';
import LeaveChannel from './leave_channel';
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
leaveChannel,
openModal,
}, dispatch),
};
}
const connector = connect(null, mapDispatchToProps);
export type PropsFromRedux = ConnectedProps<typeof connector>;
export default connector(LeaveChannel);

Просмотреть файл

@@ -1,86 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import Menu from 'components/widgets/menu/menu';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import LeaveChannel from './leave_channel';
describe('components/ChannelHeaderDropdown/MenuItem.LeaveChannel', () => {
const baseProps = {
channel: TestHelper.getChannelMock({
id: 'channel_id',
type: 'O',
}),
isGuestUser: false,
isDefault: false,
actions: {
leaveChannel: jest.fn(),
openModal: jest.fn(),
},
};
it('should match snapshot', () => {
const wrapper = shallow(<LeaveChannel {...baseProps}/>);
expect(wrapper).toMatchSnapshot();
});
it('should be hidden if the channel is default channel', () => {
const props = {
...baseProps,
isDefault: true,
};
const wrapper = shallow(<LeaveChannel {...props}/>);
expect(wrapper).toMatchSnapshot();
});
it('should be hidden if the channel type is DM or GM', () => {
const props = {
...baseProps,
channel: {...baseProps.channel},
};
const makeWrapper = () => shallow(<LeaveChannel {...props}/>);
props.channel.type = 'D';
expect(makeWrapper()).toMatchSnapshot();
props.channel.type = 'G';
expect(makeWrapper()).toMatchSnapshot();
});
it('should runs leaveChannel function on click only if the channel is not private', () => {
const props = {
...baseProps,
channel: {...baseProps.channel},
actions: {...baseProps.actions},
};
const wrapper = shallow(<LeaveChannel {...props}/>);
wrapper.find(Menu.ItemAction).simulate('click', {
preventDefault: jest.fn(),
});
expect(props.actions.leaveChannel).toHaveBeenCalledWith(props.channel.id);
expect(props.actions.openModal).not.toHaveBeenCalled();
props.channel.type = 'P';
props.actions.leaveChannel = jest.fn();
wrapper.find(Menu.ItemAction).simulate('click', {
preventDefault: jest.fn(),
});
expect(props.actions.leaveChannel).not.toHaveBeenCalled();
expect(props.actions.openModal).toHaveBeenCalledWith(
expect.objectContaining({
modalId: ModalIdentifiers.LEAVE_PRIVATE_CHANNEL_MODAL,
dialogProps: {
channel: props.channel,
},
}));
});
});

Просмотреть файл

@@ -1,78 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback} from 'react';
import {useIntl} from 'react-intl';
import type {Channel} from '@mattermost/types/channels';
import LeaveChannelModal from 'components/leave_channel_modal';
import Menu from 'components/widgets/menu/menu';
import {Constants, ModalIdentifiers} from 'utils/constants';
import type {PropsFromRedux} from './index';
type Props = PropsFromRedux & {
/**
* Object with info about user
*/
channel: Channel;
/**
* Boolean whether the channel is default
*/
isDefault: boolean;
/**
* Boolean whether the user is a guest or no
*/
isGuestUser: boolean;
/**
* Use for test selector
*/
id?: string;
};
const LeaveChannel = ({
isDefault = true,
isGuestUser = false,
channel,
actions: {
leaveChannel,
openModal,
},
id,
}: Props) => {
const intl = useIntl();
const handleLeave = useCallback((e: Event) => {
e.preventDefault();
if (channel.type === Constants.PRIVATE_CHANNEL) {
openModal({
modalId: ModalIdentifiers.LEAVE_PRIVATE_CHANNEL_MODAL,
dialogType: LeaveChannelModal,
dialogProps: {
channel,
},
});
} else {
leaveChannel(channel.id);
}
}, [channel, leaveChannel, openModal]);
return (
<Menu.ItemAction
id={id}
show={(!isDefault || isGuestUser) && channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL}
onClick={handleLeave}
text={intl.formatMessage({id: 'channel_header.leave', defaultMessage: 'Leave Channel'})}
isDangerous={true}
/>
);
};
export default memo(LeaveChannel);

Просмотреть файл

@@ -1,27 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {showChannelMembers} from 'actions/views/rhs';
import {getIsRhsOpen, getRhsState} from 'selectors/rhs';
import {RHSStates} from 'utils/constants';
import type {GlobalState} from 'types/store';
import OpenChannelMembersRHS from './open_members_rhs';
const mapStateToProps = (state: GlobalState) => ({
rhsOpen: getIsRhsOpen(state) && getRhsState(state) === RHSStates.CHANNEL_MEMBERS,
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
actions: bindActionCreators({
showChannelMembers,
}, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(OpenChannelMembersRHS);

Просмотреть файл

@@ -1,53 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import Menu from 'components/widgets/menu/menu';
type Action = {
showChannelMembers: (channelId: string, editMembers: boolean) => void;
};
type OwnProps = {
channel: Channel;
show: boolean;
id: string;
editMembers?: boolean;
text: string;
}
type Props = {
rhsOpen: boolean;
actions: Action;
} & OwnProps;
const ToggleChannelMembersRHS = ({
show,
id,
channel,
rhsOpen,
text,
editMembers = false,
actions,
}: Props) => {
const openRHSIfNotOpen = () => {
if (rhsOpen) {
return;
}
actions.showChannelMembers(channel.id, editMembers);
};
return (
<Menu.ItemAction
show={show}
id={id}
onClick={openRHSIfNotOpen}
text={text}
/>
);
};
export default ToggleChannelMembersRHS;

Просмотреть файл

@@ -1,17 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/ChannelHeaderDropdown/MenuItem.ToggleFavoriteChannel should match snapshot for favorite channel 1`] = `
<MenuItemAction
onClick={[Function]}
show={true}
text="Remove from Favorites"
/>
`;
exports[`components/ChannelHeaderDropdown/MenuItem.ToggleFavoriteChannel should match snapshot for not favorite channel 1`] = `
<MenuItemAction
onClick={[Function]}
show={true}
text="Add to Favorites"
/>
`;

Просмотреть файл

@@ -1,19 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {favoriteChannel, unfavoriteChannel} from 'mattermost-redux/actions/channels';
import ToggleFavoriteChannel from './toggle_favorite_channel';
const mapDispatchToProps = (dispatch: Dispatch) => ({
actions: bindActionCreators({
favoriteChannel,
unfavoriteChannel,
}, dispatch),
});
export default connect(null, mapDispatchToProps)(ToggleFavoriteChannel);

Просмотреть файл

@@ -1,88 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import type {ChannelType} from '@mattermost/types/channels';
import Menu from 'components/widgets/menu/menu';
import ToggleFavoriteChannel from './toggle_favorite_channel';
describe('components/ChannelHeaderDropdown/MenuItem.ToggleFavoriteChannel', () => {
const baseProps = {
channel: {
id: 'channel_id',
display_name: 'channel_display_name',
create_at: 0,
update_at: 0,
delete_at: 0,
team_id: '',
type: 'O' as ChannelType,
name: '',
header: '',
purpose: '',
last_post_at: 0,
last_root_post_at: 0,
creator_id: '',
scheme_id: '',
group_constrained: false,
},
actions: {
favoriteChannel: jest.fn(),
unfavoriteChannel: jest.fn(),
},
};
const propsForFavorite = {
...baseProps,
isFavorite: true,
actions: {
favoriteChannel: jest.fn(),
unfavoriteChannel: jest.fn(),
},
};
const propsForNotFavorite = {
...baseProps,
isFavorite: false,
actions: {
favoriteChannel: jest.fn(),
unfavoriteChannel: jest.fn(),
},
};
it('should match snapshot for favorite channel', () => {
const wrapper = shallow(<ToggleFavoriteChannel {...propsForFavorite}/>);
expect(wrapper).toMatchSnapshot();
});
it('should runs unfavoriteChannel function for favorite channel', () => {
const wrapper = shallow(<ToggleFavoriteChannel {...propsForFavorite}/>);
wrapper.find(Menu.ItemAction).simulate('click', {
preventDefault: jest.fn(),
});
expect(propsForFavorite.actions.unfavoriteChannel).toHaveBeenCalledWith(propsForFavorite.channel.id);
expect(propsForFavorite.actions.favoriteChannel).not.toHaveBeenCalled();
});
it('should match snapshot for not favorite channel', () => {
const wrapper = shallow(<ToggleFavoriteChannel {...propsForNotFavorite}/>);
expect(wrapper).toMatchSnapshot();
});
it('should runs favoriteChannel function for not favorite channel', () => {
const wrapper = shallow(<ToggleFavoriteChannel {...propsForNotFavorite}/>);
wrapper.find(Menu.ItemAction).simulate('click', {
preventDefault: jest.fn(),
});
expect(propsForNotFavorite.actions.favoriteChannel).toHaveBeenCalledWith(propsForFavorite.channel.id);
expect(propsForNotFavorite.actions.unfavoriteChannel).not.toHaveBeenCalled();
});
});

Просмотреть файл

@@ -1,59 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback} from 'react';
import type {MouseEvent} from 'react';
import {useIntl} from 'react-intl';
import type {Channel} from '@mattermost/types/channels';
import Menu from 'components/widgets/menu/menu';
type Action = {
favoriteChannel: (channelId: string) => void;
unfavoriteChannel: (channelId: string) => void;
};
type Props = {
show?: boolean;
channel: Channel;
isFavorite: boolean;
actions: Action;
};
const ToggleFavoriteChannel = ({
show = true,
isFavorite,
actions: {
favoriteChannel,
unfavoriteChannel,
},
channel,
}: Props) => {
const intl = useIntl();
const toggleFavoriteChannel = useCallback((channelId: string) => {
return isFavorite ? unfavoriteChannel(channelId) : favoriteChannel(channelId);
}, [isFavorite, favoriteChannel, unfavoriteChannel]);
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>): void => {
e.preventDefault();
toggleFavoriteChannel(channel.id);
}, [channel.id, toggleFavoriteChannel]);
let text;
if (isFavorite) {
text = intl.formatMessage({id: 'channelHeader.removeFromFavorites', defaultMessage: 'Remove from Favorites'});
} else {
text = intl.formatMessage({id: 'channelHeader.addToFavorites', defaultMessage: 'Add to Favorites'});
}
return (
<Menu.ItemAction
show={show}
onClick={handleClick}
text={text}
/>
);
};
export default memo(ToggleFavoriteChannel);

Просмотреть файл

@@ -1,28 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {closeRightHandSide, showChannelInfo} from 'actions/views/rhs';
import {getIsRhsOpen, getRhsState} from 'selectors/rhs';
import {RHSStates} from 'utils/constants';
import type {GlobalState} from 'types/store';
import ToggleInfo from './toggle_info';
const mapStateToProps = (state: GlobalState) => ({
rhsOpen: getIsRhsOpen(state) && getRhsState(state) === RHSStates.CHANNEL_INFO,
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
actions: bindActionCreators({
closeRightHandSide,
showChannelInfo,
}, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(ToggleInfo);

Просмотреть файл

@@ -1,50 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import type {Channel} from '@mattermost/types/channels';
import Menu from 'components/widgets/menu/menu';
type Action = {
closeRightHandSide: () => void;
showChannelInfo: (channelId: string) => void;
};
type Props = {
show: boolean;
channel: Channel;
rhsOpen: boolean;
actions: Action;
};
const ToggleInfo = ({show, channel, rhsOpen, actions}: Props) => {
const intl = useIntl();
const toggleRHS = () => {
if (rhsOpen) {
actions.closeRightHandSide();
return;
}
actions.showChannelInfo(channel.id);
};
let text;
if (rhsOpen) {
text = intl.formatMessage({id: 'channelHeader.hideInfo', defaultMessage: 'Close Info'});
} else {
text = intl.formatMessage({id: 'channelHeader.viewInfo', defaultMessage: 'View Info'});
}
return (
<Menu.ItemAction
show={show}
onClick={toggleRHS}
text={text}
/>
);
};
export default ToggleInfo;

Просмотреть файл

@@ -1,9 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/ChannelHeaderDropdown/MenuItemToggleMuteChannel should match snapshot 1`] = `
<MenuItemAction
onClick={[Function]}
show={true}
text="Mute Channel"
/>
`;

Просмотреть файл

@@ -1,18 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {updateChannelNotifyProps} from 'mattermost-redux/actions/channels';
import MenuItemToggleMuteChannel from './toggle_mute_channel';
const mapDispatchToProps = (dispatch: Dispatch) => ({
actions: bindActionCreators({
updateChannelNotifyProps,
}, dispatch),
});
export default connect(null, mapDispatchToProps)(MenuItemToggleMuteChannel);

Просмотреть файл

@@ -1,116 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import Menu from 'components/widgets/menu/menu';
import MenuItemAction from 'components/widgets/menu/menu_items/menu_item_action';
import {Constants, NotificationLevels} from 'utils/constants';
import MenuItemToggleMuteChannel from './toggle_mute_channel';
describe('components/ChannelHeaderDropdown/MenuItemToggleMuteChannel', () => {
const baseProps = {
user: {
id: 'user_id',
} as UserProfile,
channel: {
id: 'channel_id',
type: 'O',
} as Channel,
isMuted: false,
actions: {
updateChannelNotifyProps: jest.fn(),
},
};
it('should match snapshot', () => {
const wrapper = shallow(<MenuItemToggleMuteChannel {...baseProps}/>);
expect(wrapper).toMatchSnapshot();
});
it('should unmute channel on click the channel was muted', () => {
const props = {
...baseProps,
isMuted: true,
actions: {
updateChannelNotifyProps: jest.fn(),
},
};
const wrapper = shallow(<MenuItemToggleMuteChannel {...props}/>);
wrapper.find(Menu.ItemAction).simulate('click');
expect(props.actions.updateChannelNotifyProps).toBeCalledWith(
props.user.id,
props.channel.id,
{mark_unread: NotificationLevels.ALL},
);
});
it('should mute channel on click the channel was unmuted', () => {
const props = {
...baseProps,
isMuted: false,
actions: {
updateChannelNotifyProps: jest.fn(),
},
};
const wrapper = shallow(<MenuItemToggleMuteChannel {...props}/>);
wrapper.find(Menu.ItemAction).simulate('click');
expect(props.actions.updateChannelNotifyProps).toBeCalledWith(
props.user.id,
props.channel.id,
{mark_unread: NotificationLevels.MENTION},
);
});
it('should show Mute Channel to all channel types except DM_CHANNEL and GM_CHANNEL', () => {
[
Constants.OPEN_CHANNEL,
Constants.PRIVATE_CHANNEL,
Constants.ARCHIVED_CHANNEL,
].forEach((channelType) => {
const channel = {
id: 'channel_id',
type: channelType,
} as Channel;
const wrapper = shallow(
<MenuItemToggleMuteChannel
{...baseProps}
channel={channel}
/>,
);
expect(wrapper.find(MenuItemAction).props().show).toEqual(true);
expect(wrapper.find(MenuItemAction).props().text).toEqual('Mute Channel');
});
});
it('should show Mute Conversation to channel types DM_CHANNEL and GM_CHANNEL', () => {
[
Constants.DM_CHANNEL,
Constants.GM_CHANNEL,
].forEach((channelType) => {
const channel = {
id: 'channel_id',
type: channelType,
} as Channel;
const wrapper = shallow(
<MenuItemToggleMuteChannel
{...baseProps}
channel={channel}
/>,
);
expect(wrapper.find(MenuItemAction).props().show).toEqual(true);
expect(wrapper.find(MenuItemAction).props().text).toEqual('Mute Conversation');
});
});
});

Просмотреть файл

@@ -1,79 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import type {Channel, ChannelNotifyProps} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import Menu from 'components/widgets/menu/menu';
import {Constants, NotificationLevels} from 'utils/constants';
export type Actions = {
updateChannelNotifyProps(userId: string, channelId: string, props: Partial<ChannelNotifyProps>): void;
};
type Props = {
/**
* Object with info about the current user
*/
user: UserProfile;
/**
* Object with info about the current channel
*/
channel: Channel;
/**
* Boolean whether the current channel is muted
*/
isMuted: boolean;
/**
* Use for test selector
*/
id?: string;
/**
* Object with action creators
*/
actions: Actions;
};
export default function MenuItemToggleMuteChannel({
id,
isMuted,
channel,
user,
actions,
}: Props) {
const intl = useIntl();
const handleClick = useCallback(() => {
actions.updateChannelNotifyProps(user.id, channel.id, {
mark_unread: (isMuted ? NotificationLevels.ALL : NotificationLevels.MENTION) as 'all' | 'mention',
});
}, [actions, isMuted, user.id, channel.id]);
let text;
if (channel.type === Constants.DM_CHANNEL || channel.type === Constants.GM_CHANNEL) {
text = isMuted ?
intl.formatMessage({id: 'channel_header.unmuteConversation', defaultMessage: 'Unmute Conversation'}) :
intl.formatMessage({id: 'channel_header.muteConversation', defaultMessage: 'Mute Conversation'});
} else {
text = isMuted ?
intl.formatMessage({id: 'channel_header.unmute', defaultMessage: 'Unmute Channel'}) :
intl.formatMessage({id: 'channel_header.mute', defaultMessage: 'Mute Channel'});
}
return (
<Menu.ItemAction
id={id}
onClick={handleClick}
text={text}
/>
);
}

Просмотреть файл

@@ -1,9 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/ChannelHeaderDropdown/MenuItem.ViewPinnedPosts should match snapshot 1`] = `
<MenuItemAction
onClick={[Function]}
show={true}
text="View Pinned Posts"
/>
`;

Просмотреть файл

@@ -1,28 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {closeRightHandSide, showPinnedPosts} from 'actions/views/rhs';
import {getRhsState} from 'selectors/rhs';
import {RHSStates} from 'utils/constants';
import type {GlobalState} from 'types/store';
import ViewPinnedPosts from './view_pinned_posts';
const mapStateToProps = (state: GlobalState) => ({
hasPinnedPosts: getRhsState(state) === RHSStates.PIN,
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
actions: bindActionCreators({
closeRightHandSide,
showPinnedPosts,
}, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(ViewPinnedPosts);

Просмотреть файл

@@ -1,51 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import Menu from 'components/widgets/menu/menu';
import ViewPinnedPosts from './view_pinned_posts';
describe('components/ChannelHeaderDropdown/MenuItem.ViewPinnedPosts', () => {
const baseProps = {
channel: {
id: 'channel_id',
},
hasPinnedPosts: true,
actions: {
closeRightHandSide: jest.fn(),
showPinnedPosts: jest.fn(),
},
};
it('should match snapshot', () => {
const wrapper = shallow(<ViewPinnedPosts {...baseProps}/>);
expect(wrapper).toMatchSnapshot();
});
it('should runs closeRightHandSide function if has any pinned posts', () => {
const wrapper = shallow(<ViewPinnedPosts {...baseProps}/>);
wrapper.find(Menu.ItemAction).simulate('click', {
preventDefault: jest.fn(),
});
expect(baseProps.actions.closeRightHandSide).toHaveBeenCalled();
});
it('should runs showPinnedPosts function if has not pinned posts', () => {
const props = {
...baseProps,
hasPinnedPosts: false,
};
const wrapper = shallow(<ViewPinnedPosts {...props}/>);
wrapper.find(Menu.ItemAction).simulate('click', {
preventDefault: jest.fn(),
});
expect(baseProps.actions.showPinnedPosts).toHaveBeenCalled();
});
});

Просмотреть файл

@@ -1,49 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, memo} from 'react';
import type {MouseEvent} from 'react';
import {useIntl} from 'react-intl';
import Menu from 'components/widgets/menu/menu';
type Props = {
show?: boolean;
channel: any;
hasPinnedPosts: boolean;
actions: {
closeRightHandSide: () => void;
showPinnedPosts: (id: any) => void;
};
}
const ViewPinnedPosts = ({
channel,
hasPinnedPosts,
actions: {
closeRightHandSide,
showPinnedPosts,
},
show,
}: Props) => {
const intl = useIntl();
const handleClick = useCallback((e: MouseEvent) => {
e.preventDefault();
if (hasPinnedPosts) {
closeRightHandSide();
} else {
showPinnedPosts(channel.id);
}
}, [channel.id, closeRightHandSide, showPinnedPosts, hasPinnedPosts]);
return (
<Menu.ItemAction
show={show}
onClick={handleClick}
text={intl.formatMessage({id: 'navbar.viewPinnedPosts', defaultMessage: 'View Pinned Posts'})}
/>
);
};
export default memo(ViewPinnedPosts);

Просмотреть файл

@@ -1,90 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import {ChannelHeaderDropdownItems} from 'components/channel_header_dropdown';
import StatusIcon from 'components/status_icon';
import Menu from 'components/widgets/menu/menu';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import {Constants} from 'utils/constants';
import MobileChannelHeaderDropdownAnimation from './mobile_channel_header_dropdown_animation';
type Props = {
user: UserProfile;
channel?: Channel;
teammateId: string | null;
teammateIsBot?: boolean;
teammateStatus?: string;
displayName: string;
}
const MobileChannelHeaderDropdown = ({
user,
channel,
teammateId,
displayName,
teammateIsBot,
teammateStatus,
}: Props) => {
const intl = useIntl();
const getChannelTitle = () => {
if (!channel) {
return '';
}
if (channel.type === Constants.DM_CHANNEL) {
if (user.id === teammateId) {
return (
<FormattedMessage
id='channel_header.directchannel.you'
defaultMessage='{displayname} (you)'
values={{displayname: displayName}}
/>
);
}
return displayName;
}
return channel.display_name;
};
let dmHeaderIconStatus;
if (!teammateIsBot) {
dmHeaderIconStatus = (
<StatusIcon status={teammateStatus}/>
);
}
return (
<MenuWrapper animationComponent={MobileChannelHeaderDropdownAnimation}>
<a>
<span className='heading'>
{dmHeaderIconStatus}
{getChannelTitle()}
</span>
<span
className='fa fa-angle-down header-dropdown__icon'
title={intl.formatMessage({id: 'generic_icons.dropdown', defaultMessage: 'Dropdown Icon'})}
/>
</a>
<Menu ariaLabel={intl.formatMessage({id: 'channel_header.menuAriaLabel', defaultMessage: 'Channel Menu'})}>
<ChannelHeaderDropdownItems isMobile={true}/>
<div className='Menu__close visible-xs-block'>
{'×'}
</div>
</Menu>
</MenuWrapper>
);
};
export default memo(MobileChannelHeaderDropdown);

Просмотреть файл

@@ -1,36 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {ReactNode} from 'react';
import {CSSTransition} from 'react-transition-group';
const ANIMATION_DURATION = 350;
type Props = {
children?: ReactNode;
show: boolean;
};
const timeout = {
enter: ANIMATION_DURATION,
exit: ANIMATION_DURATION,
};
const MobileChannelHeaderDropdownAnimation = ({show, children}: Props) => {
return (
<CSSTransition
in={show}
classNames='mobile-channel-header-dropdown'
enter={true}
exit={true}
mountOnEnter={true}
unmountOnExit={true}
timeout={timeout}
>
{children}
</CSSTransition>
);
};
export default React.memo(MobileChannelHeaderDropdownAnimation);

Просмотреть файл

@@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import type {ReactNode} from 'react';
import React from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down';
import type {UserProfile} from '@mattermost/types/users';
import {
getCurrentChannel,
isCurrentChannelDefault,
isCurrentChannelFavorite,
isCurrentChannelMuted,
} from 'mattermost-redux/selectors/entities/channels';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {
getCurrentUser,
} from 'mattermost-redux/selectors/entities/users';
import {getChannelHeaderMenuPluginComponents} from 'selectors/plugins';
import * as Menu from 'components/menu';
import {Constants} from 'utils/constants';
import ChannelDirectMenu from './channel_header_menu_items/channel_header_direct_menu';
import ChannelGroupMenu from './channel_header_menu_items/channel_header_group_menu';
import ChannelHeaderMobileMenu from './channel_header_menu_items/channel_header_mobile_menu';
import ChannelPublicPrivateMenu from './channel_header_menu_items/channel_header_public_private_menu';
import ChannelHeaderTitleDirect from '../channel_header/channel_header_title_direct';
import ChannelHeaderTitleGroup from '../channel_header/channel_header_title_group';
type Props = {
dmUser?: UserProfile;
gmMembers?: UserProfile[];
archivedIcon?: JSX.Element;
sharedIcon?: JSX.Element;
isMobile?: boolean;
}
export default function ChannelHeaderMenu({dmUser, gmMembers, isMobile, archivedIcon, sharedIcon}: Props): JSX.Element | null {
const intl = useIntl();
const user = useSelector(getCurrentUser);
const channel = useSelector(getCurrentChannel);
const isDefault = useSelector(isCurrentChannelDefault);
const isFavorite = useSelector(isCurrentChannelFavorite);
const isMuted = useSelector(isCurrentChannelMuted);
const isLicensedForLDAPGroups = useSelector(getLicense).LDAPGroups === 'true';
const pluginMenuItems = useSelector(getChannelHeaderMenuPluginComponents);
const isReadonly = false;
if (!channel) {
return null;
}
const isDirect = (channel.type === Constants.DM_CHANNEL);
const isGroup = (channel.type === Constants.GM_CHANNEL);
let channelTitle: ReactNode = channel.display_name;
let ariaLabel = intl.formatMessage({
id: 'channel_header.otherchannel',
defaultMessage: '{displayName} Channel Menu',
}, {
displayName: channel.display_name,
});
if (isDirect && dmUser) {
channelTitle = <ChannelHeaderTitleDirect dmUser={dmUser}/>;
if (user.id === dmUser.id) {
ariaLabel = intl.formatMessage({
id: 'channel_header.directchannel',
defaultMessage: '{displayName} (you) Channel Menu',
}, {
displayName: channel.display_name,
});
}
} else if (isGroup) {
channelTitle = <ChannelHeaderTitleGroup gmMembers={gmMembers}/>;
}
const pluginItems = pluginMenuItems.map((item) => {
const handlePluginItemClick = () => {
if (item.action) {
item.action(channel.id);
}
};
return (
<Menu.Item
id={item.id + '_pluginmenuitem'}
key={item.id + '_pluginmenuitem'}
onClick={handlePluginItemClick}
labels={<span>{item.text}</span>}
/>
);
});
return (
<Menu.Container
menuButtonTooltip={{
text: channelTitle as string,
}}
menuButton={{
id: 'channelHeaderDropdownButton',
class: classNames('channel-header__trigger style--none'),
children: (
<>
{archivedIcon}
<strong
id='channelHeaderTitle'
className='heading'
>
{channelTitle as string}
</strong>
{sharedIcon}
<ChevronDownIcon size={16}/>
</>
),
'aria-label': ariaLabel.toLowerCase(),
}}
menu={{
id: 'channelHeaderDropdownMenu',
}}
transformOrigin={{
horizontal: 'left',
vertical: 'top',
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
{isDirect && (
<ChannelDirectMenu
channel={channel}
user={user}
isMuted={isMuted}
pluginItems={pluginItems}
isFavorite={isFavorite}
isMobile={isMobile || false}
/>
)}
{isGroup && (
<ChannelGroupMenu
channel={channel}
user={user}
isMuted={isMuted}
pluginItems={pluginItems}
isFavorite={isFavorite}
isMobile={isMobile || false}
/>
)}
{(!isDirect && !isGroup) && (
<ChannelPublicPrivateMenu
channel={channel}
user={user}
isMuted={isMuted}
pluginItems={pluginItems}
isFavorite={isFavorite}
isMobile={isMobile || false}
isDefault={isDefault}
isReadonly={isReadonly}
isLicensedForLDAPGroups={isLicensedForLDAPGroups}
/>
)}
<ChannelHeaderMobileMenu
isMobile={isMobile || false}
pluginItems={pluginItems}
channel={channel}
/>
</Menu.Container>
);
}

Просмотреть файл

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ReactNode} from 'react';
import React from 'react';
import {CogOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import ChannelMoveToSubMenu from 'components/channel_move_to_sub_menu';
import * as Menu from 'components/menu';
import CloseMessage from '../menu_items/close_message';
import EditConversationHeader from '../menu_items/edit_conversation_header';
import MenuItemPluginItems from '../menu_items/plugins_submenu';
import MenuItemToggleFavoriteChannel from '../menu_items/toggle_favorite_channel';
import MenuItemToggleInfo from '../menu_items/toggle_info';
import MenuItemToggleMuteChannel from '../menu_items/toggle_mute_channel';
import MenuItemViewPinnedPosts from '../menu_items/view_pinned_posts';
interface Props extends Menu.FirstMenuItemProps {
channel: Channel;
user: UserProfile;
isMuted: boolean;
isMobile: boolean;
isFavorite: boolean;
pluginItems: ReactNode[];
}
const ChannelHeaderDirectMenu = ({channel, user, isMuted, isMobile, isFavorite, pluginItems, ...rest}: Props) => {
return (
<>
<MenuItemToggleInfo
channel={channel}
{...rest}
/>
<MenuItemToggleMuteChannel
userID={user.id}
channel={channel}
isMuted={isMuted}
/>
{isMobile && (
<>
<MenuItemToggleFavoriteChannel
channelID={channel.id}
isFavorite={isFavorite}
/>
<MenuItemViewPinnedPosts
channelID={channel.id}
/>
</>
)}
<EditConversationHeader
leadingElement={<CogOutlineIcon size='18px'/>}
channel={channel}
/>
<Menu.Separator/>
<ChannelMoveToSubMenu
channel={channel}
/>
{!isMobile && (
<MenuItemPluginItems pluginItems={pluginItems}/>
)}
<Menu.Separator/>
<CloseMessage
currentUserID={user.id}
channel={channel}
/>
</>
);
};
export default ChannelHeaderDirectMenu;

Просмотреть файл

@@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ReactNode} from 'react';
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {
ChevronRightIcon,
CogOutlineIcon,
} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import {Permissions} from 'mattermost-redux/constants';
import {isGuest} from 'mattermost-redux/utils/user_utils';
import ChannelMoveToSubMenu from 'components/channel_move_to_sub_menu';
import * as Menu from 'components/menu';
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
import CloseMessage from '../menu_items/close_message';
import MenuItemConvertToPrivate from '../menu_items/convert_gm_to_private';
import EditConversationHeader from '../menu_items/edit_conversation_header';
import MenuItemNotification from '../menu_items/notification';
import MenuItemOpenMembersRHS from '../menu_items/open_members_rhs';
import MenuItemPluginItems from '../menu_items/plugins_submenu';
import MenuItemToggleFavoriteChannel from '../menu_items/toggle_favorite_channel';
import MenuItemToggleInfo from '../menu_items/toggle_info';
import MenuItemToggleMuteChannel from '../menu_items/toggle_mute_channel';
import MenuItemViewPinnedPosts from '../menu_items/view_pinned_posts';
interface Props extends Menu.FirstMenuItemProps {
channel: Channel;
user: UserProfile;
isMuted: boolean;
isMobile: boolean;
isFavorite: boolean;
pluginItems: ReactNode[];
}
const ChannelHeaderGroupMenu = ({channel, user, isMuted, isMobile, isFavorite, pluginItems, ...rest}: Props) => {
const isGroupConstrained = channel?.group_constrained === true;
const isArchived = channel.delete_at !== 0;
const {formatMessage} = useIntl();
return (
<>
<MenuItemToggleInfo
channel={channel}
{...rest}
/>
<MenuItemToggleMuteChannel
userID={user.id}
channel={channel}
isMuted={isMuted}
/>
{isMobile && (
<>
<MenuItemToggleFavoriteChannel
channelID={channel.id}
isFavorite={isFavorite}
/>
<MenuItemViewPinnedPosts
channelID={channel.id}
/>
</>
)}
{!isArchived && (
<MenuItemNotification
user={user}
channel={channel}
/>
)}
{(isArchived && isGroupConstrained && isGuest(user.roles)) && (
<EditConversationHeader
leadingElement={<CogOutlineIcon size='18px'/>}
channel={channel}
/>
)}
{(!isArchived && !isGroupConstrained && !isGuest(user.roles)) && (
<Menu.SubMenu
id={'channelSettings'}
labels={
<FormattedMessage
id='channel_header.settings'
defaultMessage='Settings'
/>
}
leadingElement={<CogOutlineIcon size={18}/>}
trailingElements={<ChevronRightIcon size={16}/>}
menuId={'channelSettings-menu'}
menuAriaLabel={formatMessage({id: 'channel_header.settings', defaultMessage: 'Settings'})}
>
<EditConversationHeader
channel={channel}
/>
<MenuItemConvertToPrivate
channel={channel}
/>
</Menu.SubMenu>
)}
<Menu.Separator/>
{(!isArchived && !isGroupConstrained) && (
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[Permissions.MANAGE_PUBLIC_CHANNEL_MEMBERS]}
>
<MenuItemOpenMembersRHS
id='channelMembers'
channel={channel}
text={
<FormattedMessage
id='channel_header.members'
defaultMessage='Members'
/>
}
/>
<Menu.Separator/>
</ChannelPermissionGate>
)}
<ChannelMoveToSubMenu channel={channel}/>
{!isMobile && (
<MenuItemPluginItems pluginItems={pluginItems}/>
)}
<Menu.Separator/>
<CloseMessage
currentUserID={user.id}
channel={channel}
/>
</>
);
};
export default ChannelHeaderGroupMenu;

Просмотреть файл

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ReactNode} from 'react';
import React, {memo} from 'react';
import type {Channel} from '@mattermost/types/channels';
import * as Menu from 'components/menu';
import MobileChannelHeaderPlugins from '../menu_items/mobile_channel_header_plugins';
type Props = {
isMobile: boolean;
channel: Channel;
pluginItems: ReactNode[];
}
const ChannelHeaderMobileMenu = (props: Props): JSX.Element => {
if (!props.isMobile) {
return <></>;
}
return (
<>
<MobileChannelHeaderPlugins
channel={props.channel}
isDropdown={true}
/>
<Menu.Separator/>
{props.pluginItems}
</>
);
};
export default memo(ChannelHeaderMobileMenu);

Просмотреть файл

@@ -0,0 +1,200 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ReactNode} from 'react';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import {Permissions} from 'mattermost-redux/constants';
import {isGuest} from 'mattermost-redux/utils/user_utils';
import ChannelMoveToSubMenu from 'components/channel_move_to_sub_menu';
import * as Menu from 'components/menu';
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
import {Constants} from 'utils/constants';
import MenuItemArchiveChannel from '../menu_items/archive_channel';
import MenuItemChannelBookmarks from '../menu_items/channel_bookmarks_submenu';
import MenuItemChannelSettings from '../menu_items/channel_settings_submenu';
import MenuItemCloseChannel from '../menu_items/close_channel';
import MenuItemGroupsMenuItems from '../menu_items/groups';
import MenuItemLeaveChannel from '../menu_items/leave_channel';
import MenuItemNotification from '../menu_items/notification';
import MenuItemOpenMembersRHS from '../menu_items/open_members_rhs';
import MenuItemPluginItems from '../menu_items/plugins_submenu';
import MenuItemToggleFavoriteChannel from '../menu_items/toggle_favorite_channel';
import MenuItemToggleInfo from '../menu_items/toggle_info';
import MenuItemToggleMuteChannel from '../menu_items/toggle_mute_channel';
import MenuItemUnarchiveChannel from '../menu_items/unarchive_channel';
import MenuItemViewPinnedPosts from '../menu_items/view_pinned_posts';
interface Props extends Menu.FirstMenuItemProps {
channel: Channel;
user: UserProfile;
isMuted: boolean;
isReadonly: boolean;
isDefault: boolean;
isMobile: boolean;
isFavorite: boolean;
isLicensedForLDAPGroups: boolean;
pluginItems: ReactNode[];
}
const ChannelHeaderPublicMenu = ({channel, user, isMuted, isReadonly, isDefault, isMobile, isFavorite, isLicensedForLDAPGroups, pluginItems, ...rest}: Props) => {
const isGroupConstrained = channel?.group_constrained === true;
const isArchived = channel.delete_at !== 0;
const isPrivate = channel?.type === Constants.PRIVATE_CHANNEL;
const channelMembersPermission = isPrivate ? Permissions.MANAGE_PRIVATE_CHANNEL_MEMBERS : Permissions.MANAGE_PUBLIC_CHANNEL_MEMBERS;
const channelDeletePermission = isPrivate ? Permissions.DELETE_PRIVATE_CHANNEL : Permissions.DELETE_PUBLIC_CHANNEL;
const channelUnarchivePermission = Permissions.MANAGE_TEAM;
return (
<>
<MenuItemToggleInfo
channel={channel}
{...rest}
/>
<MenuItemToggleMuteChannel
userID={user.id}
channel={channel}
isMuted={isMuted}
/>
{!isArchived && (
<>
<MenuItemNotification
user={user}
channel={channel}
/>
<MenuItemChannelSettings
isReadonly={isReadonly}
isDefault={isDefault}
channel={channel}
/>
<MenuItemChannelBookmarks
channel={channel}
/>
</>
)}
<Menu.Separator/>
{isMobile && (
<>
<MenuItemToggleFavoriteChannel
channelID={channel.id}
isFavorite={isFavorite}
/>
<MenuItemViewPinnedPosts
channelID={channel.id}
/>
<Menu.Separator/>
</>
)}
{(isArchived || isDefault) && (
<MenuItemOpenMembersRHS
id='channelMembers'
channel={channel}
text={
<FormattedMessage
id='channel_header.members'
defaultMessage='Members'
/>
}
/>
)}
{!isArchived && !isDefault && (
<>
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelMembersPermission]}
>
{isGroupConstrained && isLicensedForLDAPGroups && (
<MenuItemGroupsMenuItems
channel={channel}
/>
)}
<MenuItemOpenMembersRHS
id='channelMembers'
channel={channel}
text={
<FormattedMessage
id='channel_header.members'
defaultMessage='Members'
/>
}
/>
</ChannelPermissionGate>
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelMembersPermission]}
invert={true}
>
<MenuItemOpenMembersRHS
id='channelMembers'
channel={channel}
text={
<FormattedMessage
id='channel_header.members'
defaultMessage='Members'
/>
}
/>
</ChannelPermissionGate>
</>
)}
<Menu.Separator/>
<ChannelMoveToSubMenu channel={channel}/>
{!isMobile && (
<MenuItemPluginItems pluginItems={pluginItems}/>
)}
{!isDefault && (
<Menu.Separator/>
)}
{!isDefault && !isGuest(user.roles) && (
<MenuItemLeaveChannel
id='channelLeaveChannel'
channel={channel}
/>
)}
{isArchived && (
<MenuItemCloseChannel/>
)}
{!isArchived && !isDefault && (
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelDeletePermission]}
>
<MenuItemArchiveChannel
channel={channel}
/>
</ChannelPermissionGate>
)}
{isArchived && !isDefault && (
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelUnarchivePermission]}
>
<MenuItemUnarchiveChannel
channel={channel}
/>
</ChannelPermissionGate>
)}
</>
);
};
export default ChannelHeaderPublicMenu;

Просмотреть файл

@@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as modalActions from 'actions/views/modals';
import ChannelInviteModal from 'components/channel_invite_modal';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import AddChannelMembers from './add_channel_members';
describe('components/ChannelHeaderMenu/MenuItems/AddChannelMembers', () => {
const channel = TestHelper.getChannelMock({header: 'Test Header'});
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
// Mock useDispatch to return our custom dispatch function
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly', () => {
renderWithContext(
<AddChannelMembers
channel={channel}
/>, {},
);
const menuItem = screen.getByText('Add Members');
expect(menuItem).toBeInTheDocument(); // Check if text "Add Members" renders
});
test('dispatches openModal action on click', () => {
renderWithContext(
<WithTestMenuContext>
<AddChannelMembers
channel={channel}
/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Add Members');
expect(menuItem).toBeInTheDocument(); // Check if text "Add Members" renders
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.CHANNEL_INVITE,
dialogType: ChannelInviteModal,
dialogProps: {channel},
});
});
});

Просмотреть файл

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {AccountPlusOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {openModal} from 'actions/views/modals';
import ChannelInviteModal from 'components/channel_invite_modal';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
channel: Channel;
}
const AddChannelMembers = ({channel}: Props): JSX.Element => {
const dispatch = useDispatch();
const handleAddMembers = () => {
dispatch(openModal({
modalId: ModalIdentifiers.CHANNEL_INVITE,
dialogType: ChannelInviteModal,
dialogProps: {channel},
}));
};
return (
<Menu.Item
id='channelAddMembers'
leadingElement={<AccountPlusOutlineIcon size='18px'/>}
onClick={handleAddMembers}
labels={
<FormattedMessage
id='navbar.addMembers'
defaultMessage='Add Members'
/>
}
/>
);
};
export default React.memo(AddChannelMembers);

Просмотреть файл

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as modalActions from 'actions/views/modals';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import MoreDirectChannels from 'components/more_direct_channels';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import AddGroupMembers from './add_group_members';
describe('components/ChannelHeaderMenu/MenuItems/AddGroupMembers', () => {
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
// Mock useDispatch to return our custom dispatch function
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly', () => {
renderWithContext(
<AddGroupMembers/>, {},
);
const menuItem = screen.getByText('Add Members');
expect(menuItem).toBeInTheDocument(); // Check if text "Add Members" renders
});
test('dispatches openModal action on click', () => {
renderWithContext(
<WithTestMenuContext>
<AddGroupMembers/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Add Members');
expect(menuItem).toBeInTheDocument(); // Check if text "Add Members" renders
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.CREATE_DM_CHANNEL,
dialogType: MoreDirectChannels,
dialogProps: {
focusOriginElement: 'channelHeaderDropdownButton',
isExistingChannel: true,
},
});
});
});

Просмотреть файл

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {AccountMultipleOutlineIcon} from '@mattermost/compass-icons/components';
import {openModal} from 'actions/views/modals';
import * as Menu from 'components/menu';
import MoreDirectChannels from 'components/more_direct_channels';
import {ModalIdentifiers} from 'utils/constants';
const AddGroupMembers = (): JSX.Element => {
const dispatch = useDispatch();
const handleAddGroupMembers = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.CREATE_DM_CHANNEL,
dialogType: MoreDirectChannels,
dialogProps: {isExistingChannel: true, focusOriginElement: 'channelHeaderDropdownButton'},
}),
);
};
return (
<Menu.Item
id='channelAddMembers'
leadingElement={<AccountMultipleOutlineIcon size='18px'/>}
onClick={handleAddGroupMembers}
labels={
<FormattedMessage
id='navbar.addMembers'
defaultMessage='Add Members'
/>
}
/>
);
};
export default React.memo(AddGroupMembers);

Просмотреть файл

@@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as modalActions from 'actions/views/modals';
import LocalStorageStore from 'stores/local_storage_store';
import DeleteChannelModal from 'components/delete_channel_modal';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import ArchiveChannel from './archive_channel';
describe('components/ChannelHeaderMenu/MenuItems/ArchiveChannel', () => {
const initialState = {
entities: {
channels: {
currentChannelId: 'current_channel_id',
channels: {
current_channel_id: TestHelper.getChannelMock({
id: 'current_channel_id',
name: 'default-name',
display_name: 'Default',
delete_at: 0,
type: 'O',
team_id: 'team_id',
}),
},
},
teams: {
currentTeamId: 'team-id',
teams: {
'team-id': {
id: 'team_id',
name: 'team-1',
display_name: 'Team 1',
},
},
myMembers: {
'team-id': {roles: 'team_role'},
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {
locale: 'en',
roles: 'system_role'},
},
},
},
};
LocalStorageStore.setPenultimateChannelName('current_user_id', 'team-id', 'current_channel_id');
const channel = TestHelper.getChannelMock({header: 'Test Header'});
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
// Mock useDispatch to return our custom dispatch function
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly', () => {
renderWithContext(
<ArchiveChannel channel={channel}/>, initialState,
);
const menuItem = screen.getByText('Archive Channel');
expect(menuItem).toBeInTheDocument(); // Check if text "Add Members" renders
});
test('dispatches openModal action on click with default channel', () => {
renderWithContext(
<WithTestMenuContext>
<ArchiveChannel channel={channel}/>
</WithTestMenuContext>, initialState,
);
const menuItem = screen.getByText('Archive Channel');
expect(menuItem).toBeInTheDocument(); // Check if text "Add Members" renders
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.DELETE_CHANNEL,
dialogType: DeleteChannelModal,
dialogProps: {
channel,
penultimateViewedChannelName: 'current_channel_id',
},
});
});
});

Просмотреть файл

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {ArchiveOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {getRedirectChannelNameForCurrentTeam} from 'mattermost-redux/selectors/entities/channels';
import {openModal} from 'actions/views/modals';
import {getPenultimateViewedChannelName} from 'selectors/local_storage';
import DeleteChannelModal from 'components/delete_channel_modal';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
channel: Channel;
}
const ArchiveChannel = ({
channel,
}: Props) => {
const dispatch = useDispatch();
const redirectChannelName = useSelector(getRedirectChannelNameForCurrentTeam);
const penultimateViewedChannelName = useSelector(getPenultimateViewedChannelName) || redirectChannelName;
const handleArchiveChannel = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.DELETE_CHANNEL,
dialogType: DeleteChannelModal,
dialogProps: {
channel,
penultimateViewedChannelName,
},
}),
);
};
return (
<Menu.Item
id='channelArchiveChannel'
leadingElement={<ArchiveOutlineIcon size={18}/>}
onClick={handleArchiveChannel}
labels={
<FormattedMessage
id='channel_header.delete'
defaultMessage='Archive Channel'
/>
}
isDestructive={true}
/>
);
};
export default memo(ArchiveChannel);

Просмотреть файл

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Purpose of this file to exists is only required until channel header dropdown is migrated to new menus
import React, {memo} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {
ChevronRightIcon,
LinkVariantIcon,
PaperclipIcon,
BookmarkOutlineIcon,
} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {getChannelBookmarks} from 'mattermost-redux/selectors/entities/channel_bookmarks';
import {useBookmarkAddActions} from 'components/channel_bookmarks/channel_bookmarks_menu';
import {MAX_BOOKMARKS_PER_CHANNEL, useCanUploadFiles, useChannelBookmarkPermission} from 'components/channel_bookmarks/utils';
import * as Menu from 'components/menu';
import type {GlobalState} from 'types/store';
type Props = {
channel: Channel;
};
const ChannelBookmarksSubmenu = (props: Props) => {
const {formatMessage} = useIntl();
const {handleCreateLink, handleCreateFile} = useBookmarkAddActions(props.channel.id);
const canAdd = useChannelBookmarkPermission(props.channel.id, 'add');
const canUploadFiles = useCanUploadFiles();
useSelector((state: GlobalState) => {
const bookmarks = getChannelBookmarks(state, props.channel.id);
return bookmarks && Object.keys(bookmarks).length >= MAX_BOOKMARKS_PER_CHANNEL;
});
if (!canAdd) {
return null;
}
return (
<Menu.SubMenu
id={`channel-menu-${props.channel.id}-bookmarks`}
leadingElement={<BookmarkOutlineIcon size={18}/>}
labels={(
<FormattedMessage
id='channel_menu.bookmarks'
defaultMessage='Bookmarks Bar'
/>
)}
trailingElements={(
<ChevronRightIcon size={16}/>
)}
menuId={`channel-menu-${props.channel.id}-menu`}
menuAriaLabel={formatMessage({id: 'channel_menu.bookmarks', defaultMessage: 'Bookmarks Bar'})}
>
<Menu.Item
id={`channel-menu-${props.channel.id}-bookmarks-link`}
leadingElement={<LinkVariantIcon size={18}/>}
labels={(
<FormattedMessage
id='channel_menu.bookmarks.addLink'
defaultMessage='Add a link'
/>
)}
onClick={() => handleCreateLink()}
/>
{canUploadFiles && (
<Menu.Item
id={`channel-menu-${props.channel.id}-bookmarks-file`}
leadingElement={<PaperclipIcon size={18}/>}
labels={(
<FormattedMessage
id='channel_menu.bookmarks.addFile'
defaultMessage='Attach a file'
/>
)}
onClick={() => handleCreateFile()}
/>
)}
</Menu.SubMenu>
);
};
export default memo(ChannelBookmarksSubmenu);

Просмотреть файл

@@ -0,0 +1,167 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {
ChevronRightIcon,
CogOutlineIcon,
} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {Permissions} from 'mattermost-redux/constants';
import {openModal} from 'actions/views/modals';
import ConvertChannelModal from 'components/convert_channel_modal';
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
import EditChannelPurposeModal from 'components/edit_channel_purpose_modal';
import * as Menu from 'components/menu';
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
import RenameChannelModal from 'components/rename_channel_modal';
import {Constants, ModalIdentifiers} from 'utils/constants';
type Props = {
channel: Channel;
isReadonly: boolean;
isDefault: boolean;
}
const ChannelSettingsSubmenu = ({channel, isReadonly, isDefault}: Props): JSX.Element => {
const dispatch = useDispatch();
const {formatMessage} = useIntl();
const channelPropertiesPermission = channel.type === Constants.PRIVATE_CHANNEL ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES;
const handleRenameChannel = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.RENAME_CHANNEL,
dialogType: RenameChannelModal,
dialogProps: {channel},
}),
);
};
const handleEditHeader = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
dialogType: EditChannelHeaderModal,
dialogProps: {channel},
}),
);
};
const handleEditPurpose = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE,
dialogType: EditChannelPurposeModal,
dialogProps: {channel},
}),
);
};
const handleConvertToPrivate = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.CONVERT_CHANNEL,
dialogType: ConvertChannelModal,
dialogProps: {
channelId: channel.id,
channelDisplayName: channel.display_name,
},
}),
);
};
return (
<Menu.SubMenu
id={'channelSettings'}
labels={
<FormattedMessage
id='channelSettings'
defaultMessage='Channel Settings'
/>
}
leadingElement={<CogOutlineIcon size={18}/>}
trailingElements={<ChevronRightIcon size={16}/>}
menuId={'channelSettings-menu'}
menuAriaLabel={formatMessage({id: 'channelSettings', defaultMessage: 'Channel Settings'})}
>
{!isReadonly && (
<Menu.Item
id='channelRename'
onClick={handleRenameChannel}
labels={
<FormattedMessage
id='channel_header.rename'
defaultMessage='Rename Channel'
/>
}
/>
)}
{!isReadonly && (
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelPropertiesPermission]}
>
<Menu.Item
id='channelEditHeader'
onClick={handleEditHeader}
labels={
<FormattedMessage
id='channel_header.setHeader'
defaultMessage='Edit Channel Header'
/>
}
/>
</ChannelPermissionGate>
)}
{!isReadonly && (
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelPropertiesPermission]}
>
<Menu.Item
id='channelEditPurpose'
onClick={handleEditPurpose}
labels={
<FormattedMessage
id='channel_header.setPurpose'
defaultMessage='Edit Channel Purpose'
/>
}
/>
</ChannelPermissionGate>
)}
{!isDefault && channel.type === Constants.OPEN_CHANNEL && (
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE]}
>
<Menu.Item
id='channelConvertToPrivate'
onClick={handleConvertToPrivate}
labels={
<FormattedMessage
id='channel_header.convert'
defaultMessage='Convert to Private Channel'
/>
}
/>
</ChannelPermissionGate>
)}
</Menu.SubMenu>
);
};
export default memo(ChannelSettingsSubmenu);

Просмотреть файл

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import * as channelActions from 'actions/views/channel';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import CloseChannel from './close_channel';
describe('components/ChannelHeaderMenu/MenuItems/CloseChannel', () => {
beforeEach(() => {
jest.spyOn(channelActions, 'goToLastViewedChannel');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly, handle click event', () => {
renderWithContext(
<WithTestMenuContext>
<CloseChannel/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Close Channel');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(channelActions.goToLastViewedChannel).toHaveBeenCalledTimes(1); // Ensure dispatch was called
});
});

Просмотреть файл

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {goToLastViewedChannel} from 'actions/views/channel';
import * as Menu from 'components/menu';
interface Props extends Menu.FirstMenuItemProps {}
const CloseChannel = ({...rest}: Props): JSX.Element => {
return (
<Menu.Item
onClick={goToLastViewedChannel}
labels={
<FormattedMessage
id='center_panel.archived.closeChannel'
defaultMessage='Close Channel'
/>}
{...rest}
/>
);
};
export default React.memo(CloseChannel);

Просмотреть файл

@@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {ChannelType} from '@mattermost/types/channels';
import * as preferences from 'mattermost-redux/actions/preferences';
import * as channelActions from 'actions/views/channel';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {Constants} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import CloseMessage from './close_message';
describe('components/ChannelHeaderMenu/MenuItems/CloseMessage', () => {
const initialState = {
entities: {
channels: {
currentChannelId: 'current_channel_id',
channels: {
current_channel_id: TestHelper.getChannelMock({
id: 'current_channel_id',
name: 'default-name',
display_name: 'Default',
delete_at: 0,
type: 'O',
team_id: 'team_id',
}),
},
},
teams: {
currentTeamId: 'team-id',
teams: {
'team-id': {
id: 'team_id',
name: 'team-1',
display_name: 'Team 1',
},
},
myMembers: {
'team-id': {roles: 'team_role'},
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {
locale: 'en',
roles: 'system_role',
},
},
},
},
};
const groupChannel = TestHelper.getChannelMock({
id: 'channel_id',
name: 'groupChannel',
type: Constants.GM_CHANNEL as ChannelType,
});
const directChannel = TestHelper.getChannelMock({
id: 'channel_id',
type: Constants.DM_CHANNEL as ChannelType,
teammate_id: 'teammate-id',
name: 'directChannel',
});
beforeEach(() => {
jest.spyOn(channelActions, 'leaveDirectChannel');
jest.spyOn(preferences, 'savePreferences');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly for group channel', () => {
renderWithContext(
<WithTestMenuContext>
<CloseMessage
currentUserID='current_user_id'
channel={groupChannel}
/>
</WithTestMenuContext>, initialState,
);
const menuItem = screen.getByText('Close Group Message');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(channelActions.leaveDirectChannel).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(channelActions.leaveDirectChannel).toHaveBeenCalledWith(groupChannel.name);
expect(preferences.savePreferences).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(preferences.savePreferences).toHaveBeenCalledWith(
'current_user_id',
[{user_id: 'current_user_id', category: Constants.Preferences.CATEGORY_GROUP_CHANNEL_SHOW, name: groupChannel.id, value: 'false'}],
);
});
test('renders the component correctly for direct channel', () => {
renderWithContext(
<WithTestMenuContext>
<CloseMessage
currentUserID='current_user_id'
channel={directChannel}
/>
</WithTestMenuContext>, initialState,
);
const menuItem = screen.getByText('Close Direct Message');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(channelActions.leaveDirectChannel).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(channelActions.leaveDirectChannel).toHaveBeenCalledWith(directChannel.name);
expect(preferences.savePreferences).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(preferences.savePreferences).toHaveBeenCalledWith(
'current_user_id',
[{user_id: 'current_user_id', category: Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, name: directChannel.teammate_id, value: 'false'}],
);
});
});

Просмотреть файл

@@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {CloseIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getRedirectChannelNameForCurrentTeam} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {leaveDirectChannel} from 'actions/views/channel';
import * as Menu from 'components/menu';
import {getHistory} from 'utils/browser_history';
import {Constants} from 'utils/constants';
type Props = {
currentUserID: string;
channel: Channel;
id?: string;
};
export default function CloseMessage(props: Props) {
const dispatch = useDispatch();
const currentTeam = useSelector(getCurrentTeam);
const redirectChannel = useSelector(getRedirectChannelNameForCurrentTeam);
const handleClose = () => {
const {
channel,
currentUserID,
} = props;
let name: string;
let category;
if (channel.type === Constants.DM_CHANNEL) {
category = Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW;
name = channel.teammate_id!;
} else {
category = Constants.Preferences.CATEGORY_GROUP_CHANNEL_SHOW;
name = channel.id;
}
dispatch(leaveDirectChannel(channel.name));
dispatch(savePreferences(currentUserID, [{user_id: currentUserID, category, name, value: 'false'}]));
if (currentTeam) {
getHistory().push(`/${currentTeam.name}/channels/${redirectChannel}`);
}
};
const {id, channel} = props;
// DM
let text = (
<FormattedMessage
id='center_panel.direct.closeDirectMessage'
defaultMessage='Close Direct Message'
/>);
if (channel.type === Constants.GM_CHANNEL) {
text = (
<FormattedMessage
id='center_panel.direct.closeGroupMessage'
defaultMessage='Close Group Message'
/>);
}
return (
<Menu.Item
id={id}
leadingElement={<CloseIcon size='18px'/>}
onClick={handleClose}
labels={text}
/>
);
}

Просмотреть файл

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as modalActions from 'actions/views/modals';
import ConvertGmToChannelModal from 'components/convert_gm_to_channel_modal';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import ConvertGMtoPrivate from './convert_gm_to_private';
describe('components/ChannelHeaderMenu/MenuItems/ConvertGMtoPrivate', () => {
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
// Mock useDispatch to return our custom dispatch function
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
const channel = TestHelper.getChannelMock();
test('renders the component correctly, handle click event', () => {
renderWithContext(
<WithTestMenuContext>
<ConvertGMtoPrivate channel={channel}/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Convert to Private Channel');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.CONVERT_GM_TO_CHANNEL,
dialogType: ConvertGmToChannelModal,
dialogProps: {channel},
});
});
});

Просмотреть файл

@@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import {openModal} from 'actions/views/modals';
import ConvertGmToChannelModal from 'components/convert_gm_to_channel_modal';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
channel: Channel;
}
const ConvertGMtoPrivate = ({channel}: Props): JSX.Element => {
const dispatch = useDispatch();
const handleConvertToPrivate = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.CONVERT_GM_TO_CHANNEL,
dialogType: ConvertGmToChannelModal,
dialogProps: {channel},
}),
);
};
return (
<Menu.Item
id='convertGMPrivateChannel'
onClick={handleConvertToPrivate}
labels={
<FormattedMessage
id='sidebar_left.sidebar_channel_menu_convert_to_channel'
defaultMessage='Convert to Private Channel'
/>
}
/>
);
};
export default React.memo(ConvertGMtoPrivate);

Просмотреть файл

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as modalActions from 'actions/views/modals';
import ConvertChannelModal from 'components/convert_channel_modal';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import ConvertPublictoPrivate from './convert_public_to_private';
describe('components/ChannelHeaderMenu/MenuItems/ConvertPublicToPrivate', () => {
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
// Mock useDispatch to return our custom dispatch function
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
const channel = TestHelper.getChannelMock();
test('renders the component correctly, handle click event', () => {
renderWithContext(
<WithTestMenuContext>
<ConvertPublictoPrivate channel={channel}/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Convert to Private Channel');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.CONVERT_CHANNEL,
dialogType: ConvertChannelModal,
dialogProps: {
channelId: channel.id,
channelDisplayName: channel.display_name,
},
});
});
});

Просмотреть файл

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import {openModal} from 'actions/views/modals';
import ConvertChannelModal from 'components/convert_channel_modal';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
channel: Channel;
}
const ConvertPublictoPrivate = ({channel}: Props): JSX.Element => {
const dispatch = useDispatch();
const handleConvertToPrivate = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.CONVERT_CHANNEL,
dialogType: ConvertChannelModal,
dialogProps: {
channelId: channel.id,
channelDisplayName: channel.display_name,
},
}),
);
};
return (
<Menu.Item
id='channelConvertToPrivate'
onClick={handleConvertToPrivate}
labels={
<FormattedMessage
id='channel_header.convert'
defaultMessage='Convert to Private Channel'
/>
}
/>
);
};
export default React.memo(ConvertPublictoPrivate);

Просмотреть файл

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as modalActions from 'actions/views/modals';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import RenameChannelModal from 'components/rename_channel_modal';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import EditChannelSettings from './edit_channel_settings';
describe('components/ChannelHeaderMenu/MenuItems/EditChannelSettings', () => {
const channel = TestHelper.getChannelMock();
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly, handle click event', () => {
renderWithContext(
<WithTestMenuContext>
<EditChannelSettings
channel={channel}
isReadonly={false}
isDefault={false}
/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Rename Channel');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.RENAME_CHANNEL,
dialogType: RenameChannelModal,
dialogProps: {channel},
});
});
});

Просмотреть файл

@@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import {Permissions} from 'mattermost-redux/constants';
import {openModal} from 'actions/views/modals';
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
import EditChannelPurposeModal from 'components/edit_channel_purpose_modal';
import * as Menu from 'components/menu';
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
import RenameChannelModal from 'components/rename_channel_modal';
import {Constants, ModalIdentifiers} from 'utils/constants';
import MenuItemConvertToPrivate from './convert_public_to_private';
type Props = {
channel: Channel;
isReadonly: boolean;
isDefault: boolean;
}
const EditChannelSettings = ({channel, isReadonly, isDefault}: Props): JSX.Element => {
const dispatch = useDispatch();
const channelPropertiesPermission = channel.type === Constants.PRIVATE_CHANNEL ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES;
const handleRenameChannel = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.RENAME_CHANNEL,
dialogType: RenameChannelModal,
dialogProps: {channel},
}),
);
};
const handleEditHeader = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
dialogType: EditChannelHeaderModal,
dialogProps: {channel},
}),
);
};
const handleEditPurpose = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE,
dialogType: EditChannelPurposeModal,
dialogProps: {channel},
}),
);
};
return (
<>
{!isReadonly && (
<>
<Menu.Item
id='channelRename'
onClick={handleRenameChannel}
labels={
<FormattedMessage
id='channel_header.rename'
defaultMessage='Rename Channel'
/>
}
/>
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelPropertiesPermission]}
>
<Menu.Item
id='channelEditHeader'
onClick={handleEditHeader}
labels={
<FormattedMessage
id='channel_header.setHeader'
defaultMessage='Edit Channel Header'
/>
}
/>
<Menu.Item
id='channelEditPurpose'
onClick={handleEditPurpose}
labels={
<FormattedMessage
id='channel_header.setPurpose'
defaultMessage='Edit Channel Purpose'
/>
}
/>
</ChannelPermissionGate>
</>
)}
{!isDefault && channel.type === Constants.OPEN_CHANNEL && (
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE]}
>
<MenuItemConvertToPrivate
channel={channel}
/>
</ChannelPermissionGate>
)}
</>
);
};
export default React.memo(EditChannelSettings);

Просмотреть файл

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as modalActions from 'actions/views/modals';
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import EditConversationHeader from './edit_conversation_header';
describe('components/ChannelHeaderMenu/MenuItems/EditConversationHeader', () => {
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
// Mock useDispatch to return our custom dispatch function
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
const channel = TestHelper.getChannelMock();
test('renders the component correctly, handle click event', () => {
renderWithContext(
<WithTestMenuContext>
<EditConversationHeader channel={channel}/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Edit Header');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
dialogType: EditChannelHeaderModal,
dialogProps: {channel},
});
});
});

Просмотреть файл

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import {openModal} from 'actions/views/modals';
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
channel: Channel;
leadingElement?: React.ReactNode;
}
const EditConversationHeader = ({channel, leadingElement}: Props): JSX.Element => {
const dispatch = useDispatch();
const handleEditHeader = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
dialogType: EditChannelHeaderModal,
dialogProps: {channel},
}),
);
};
return (
<Menu.Item
id='channelEditHeader'
leadingElement={leadingElement}
onClick={handleEditHeader}
labels={
<FormattedMessage
id='channel_header.setConversationHeader'
defaultMessage='Edit Header'
/>
}
/>
);
};
export default React.memo(EditConversationHeader);

Просмотреть файл

@@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as modalActions from 'actions/views/modals';
import AddGroupsToChannelModal from 'components/add_groups_to_channel_modal';
import ChannelGroupsManageModal from 'components/channel_groups_manage_modal';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import Groups from './groups';
describe('components/ChannelHeaderMenu/MenuItems/Groups', () => {
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
// Mock useDispatch to return our custom dispatch function
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
const channel = TestHelper.getChannelMock();
test('renders the component correctly, handle click event for add groups', () => {
renderWithContext(
<WithTestMenuContext>
<Groups channel={channel}/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Add Groups');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.ADD_GROUPS_TO_CHANNEL,
dialogType: AddGroupsToChannelModal,
});
});
test('renders the component correctly, handle click event for manage groups', () => {
renderWithContext(
<WithTestMenuContext>
<Groups channel={channel}/>
</WithTestMenuContext>, {},
);
const menuItemMG = screen.getByText('Manage Groups');
expect(menuItemMG).toBeInTheDocument();
fireEvent.click(menuItemMG); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.MANAGE_CHANNEL_GROUPS,
dialogType: ChannelGroupsManageModal,
dialogProps: {channelID: channel.id},
});
});
});

Просмотреть файл

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {AccountMultipleOutlineIcon, AccountMultiplePlusOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {openModal} from 'actions/views/modals';
import AddGroupsToChannelModal from 'components/add_groups_to_channel_modal';
import ChannelGroupsManageModal from 'components/channel_groups_manage_modal';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
interface Props extends Menu.FirstMenuItemProps {
channel: Channel;
}
const Groups = ({channel, ...rest}: Props): JSX.Element => {
const dispatch = useDispatch();
const handleAddGroups = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.ADD_GROUPS_TO_CHANNEL,
dialogType: AddGroupsToChannelModal,
}),
);
};
const handleManageGroups = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.MANAGE_CHANNEL_GROUPS,
dialogType: ChannelGroupsManageModal,
dialogProps: {channelID: channel.id},
}),
);
};
return (
<>
<Menu.Item
id='channelAddGroups'
leadingElement={<AccountMultiplePlusOutlineIcon size='18px'/>}
onClick={handleAddGroups}
labels={
<FormattedMessage
id='navbar.addGroups'
defaultMessage='Add Groups'
/>
}
{...rest}
/>
<Menu.Item
id='channelManageGroups'
leadingElement={<AccountMultipleOutlineIcon size='18px'/>}
onClick={handleManageGroups}
labels={
<FormattedMessage
id='navbar_dropdown.manageGroups'
defaultMessage='Manage Groups'
/>
}
{...rest}
/>
</>
);
};
export default React.memo(Groups);

Просмотреть файл

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as channelActions from 'actions/views/channel';
import * as modalActions from 'actions/views/modals';
import LeaveChannelModal from 'components/leave_channel_modal';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import LeaveChannel from './leave_channel';
describe('components/ChannelHeaderMenu/MenuItems/LeaveChannelTest', () => {
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
jest.spyOn(channelActions, 'leaveChannel');
// Mock useDispatch to return our custom dispatch function
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly, handle click event correctly for Public Channel', () => {
const channel = TestHelper.getChannelMock();
renderWithContext(
<WithTestMenuContext>
<LeaveChannel channel={channel}/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Leave Channel');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(channelActions.leaveChannel).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(channelActions.leaveChannel).toHaveBeenCalledWith(channel.id);
});
test('renders the component correctly, handle click event for manage groups', () => {
const channel = TestHelper.getChannelMock({type: 'P'});
renderWithContext(
<WithTestMenuContext>
<LeaveChannel channel={channel}/>
</WithTestMenuContext>, {},
);
const menuItemMG = screen.getByText('Leave Channel');
expect(menuItemMG).toBeInTheDocument();
fireEvent.click(menuItemMG); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.LEAVE_PRIVATE_CHANNEL_MODAL,
dialogType: LeaveChannelModal,
dialogProps: {
channel,
},
});
});
});

Просмотреть файл

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {LogoutVariantIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {leaveChannel} from 'actions/views/channel';
import {openModal} from 'actions/views/modals';
import LeaveChannelModal from 'components/leave_channel_modal';
import * as Menu from 'components/menu';
import {Constants, ModalIdentifiers} from 'utils/constants';
// import type {PropsFromRedux} from './index';
type Props = {
channel: Channel;
id?: string;
}
const LeaveChannel = ({
channel,
id,
}: Props) => {
const dispatch = useDispatch();
const handleLeave = () => {
if (channel.type === Constants.PRIVATE_CHANNEL) {
dispatch(
openModal({
modalId: ModalIdentifiers.LEAVE_PRIVATE_CHANNEL_MODAL,
dialogType: LeaveChannelModal,
dialogProps: {
channel,
},
}),
);
} else {
dispatch(leaveChannel(channel.id));
}
};
return (
<Menu.Item
id={id}
leadingElement={<LogoutVariantIcon size='18px'/>}
onClick={handleLeave}
labels={
<FormattedMessage
id='channel_header.leave'
defaultMessage='Leave Channel'
/>
}
isDestructive={true}
/>
);
};
export default memo(LeaveChannel);

Просмотреть файл

@@ -0,0 +1,369 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import cloneDeep from 'lodash/cloneDeep';
import React from 'react';
import {useDispatch} from 'react-redux';
import {AppBindingLocations, AppCallResponseTypes} from 'mattermost-redux/constants/apps';
import * as appsActions from 'actions/apps';
import * as channelActions from 'actions/views/channel';
import * as modalActions from 'actions/views/modals';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent, waitFor} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import MobileChannelHeaderPlugins from './mobile_channel_header_plugins';
describe('components/ChannelHeaderMenu/MenuItems/MobileChannelHeaderPlugins, with no extended components', () => {
jest.mock('actions/apps', () => ({
...jest.requireActual('actions/apps'),
handleBindingClick: jest.fn(),
}));
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
jest.spyOn(channelActions, 'leaveChannel');
// jest.spyOn(appsActions, 'handleBindingClick');
jest.spyOn(appsActions, 'openAppsModal');
jest.spyOn(appsActions, 'postEphemeralCallResponseForChannel');
// Mock useDispatch to return our custom dispatch function
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
const channel = TestHelper.getChannelMock();
const action = jest.fn();
const pluginState = {
plugins: {
components: {
MobileChannelHeaderButton: [
{
id: 'someid',
pluginId: 'pluginid',
icon: <i className='fa fa-anchor'/>,
action,
dropdownText: 'some dropdown text',
},
],
},
},
};
const bindingState = {
entities: {
apps: {
main: {
bindings: [
{
app_id: 'appid',
location: AppBindingLocations.CHANNEL_HEADER_ICON,
icon: 'http://test.com/icon.png',
label: 'Label',
hint: 'Hint',
bindings: [
{
app_id: 'app1',
location: 'channel-header-1',
label: 'App 1 Channel Header',
form: {
submit: {
path: '/call/path',
},
},
},
],
},
],
},
},
general: {
config: {
FeatureFlagAppsEnabled: 'true',
},
},
},
};
test('renders the component correctly', () => {
const {container} = renderWithContext(
<WithTestMenuContext>
<MobileChannelHeaderPlugins
channel={channel}
isDropdown={true}
/>
</WithTestMenuContext>, {},
);
expect(container.firstChild).toBeNull();
});
test('renders the component correctly, with one extended component, and handle click event', () => {
renderWithContext(
<WithTestMenuContext>
<MobileChannelHeaderPlugins
channel={channel}
isDropdown={true}
/>
</WithTestMenuContext>, pluginState,
);
const menuItem = screen.getByText('some dropdown text');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem);
expect(action).toHaveBeenCalledTimes(1);
});
test('renders the component correctly, with two extended component', () => {
const testState = {
...pluginState,
plugins: {
...pluginState.plugins,
components: {
...pluginState.plugins.components,
MobileChannelHeaderButton: [
...pluginState.plugins.components.MobileChannelHeaderButton,
{
id: 'someid2',
pluginId: 'pluginid2',
icon: <i className='fa fa-anchor'/>,
action: jest.fn(),
dropdownText: 'some other dropdown text',
},
],
},
},
};
renderWithContext(
<WithTestMenuContext>
<MobileChannelHeaderPlugins
channel={channel}
isDropdown={true}
/>
</WithTestMenuContext>, testState,
);
const menuItem = screen.getByText('some dropdown text');
expect(menuItem).toBeInTheDocument();
const menuItem2 = screen.getByText('some other dropdown text');
expect(menuItem2).toBeInTheDocument();
});
test('renders the component correctly, with two extended bindings', () => {
const testState = cloneDeep(bindingState);
testState.entities.apps.main.bindings[0].bindings.push({
app_id: 'app2',
location: 'channel-header-2',
label: 'App 2 Channel Header',
form: {
submit: {
path: '/call/path',
},
},
});
renderWithContext(
<WithTestMenuContext>
<MobileChannelHeaderPlugins
channel={channel}
isDropdown={true}
/>
</WithTestMenuContext>, testState,
);
const menuItem = screen.getByText('App 1 Channel Header');
expect(menuItem).toBeInTheDocument();
const menuItem2 = screen.getByText('App 2 Channel Header');
expect(menuItem2).toBeInTheDocument();
});
test('Processes handleBinding, returns AppCallResponseTypes.OK', async () => {
jest.spyOn(appsActions, 'handleBindingClick').mockReturnValueOnce(() => {
return Promise.resolve({
data: {
type: AppCallResponseTypes.OK,
text: 'hello',
},
});
});
renderWithContext(
<WithTestMenuContext>
<MobileChannelHeaderPlugins
channel={channel}
isDropdown={true}
/>
</WithTestMenuContext>, bindingState,
);
const menuItem = screen.getByText('App 1 Channel Header');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem);
await waitFor(() => {
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(appsActions.handleBindingClick).toHaveBeenCalledTimes(1);
expect(appsActions.postEphemeralCallResponseForChannel).toHaveBeenCalledTimes(1);
});
});
test('Processes handleBinding, returns AppCallResponseTypes.Form', async () => {
jest.spyOn(appsActions, 'handleBindingClick').mockReturnValueOnce(() => {
return Promise.resolve({
data: {
type: AppCallResponseTypes.FORM,
form: {
submit: {
path: '/call/path',
},
},
},
});
});
renderWithContext(
<WithTestMenuContext>
<MobileChannelHeaderPlugins
channel={channel}
isDropdown={true}
/>
</WithTestMenuContext>, bindingState,
);
const menuItem = screen.getByText('App 1 Channel Header');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem);
await waitFor(() => {
// expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(appsActions.handleBindingClick).toHaveBeenCalledTimes(1);
expect(appsActions.openAppsModal).toHaveBeenCalledTimes(1);
});
});
test('Processes handleBinding, returns Error', async () => {
jest.spyOn(appsActions, 'handleBindingClick').mockReturnValueOnce(() => {
return Promise.resolve({
error: {
type: AppCallResponseTypes.ERROR,
text: 'Error returned from method',
},
});
});
renderWithContext(
<WithTestMenuContext>
<MobileChannelHeaderPlugins
channel={channel}
isDropdown={true}
/>
</WithTestMenuContext>, bindingState,
);
const menuItem = screen.getByText('App 1 Channel Header');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem);
await waitFor(() => {
// expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(appsActions.handleBindingClick).toHaveBeenCalledTimes(1);
expect(appsActions.postEphemeralCallResponseForChannel).toHaveBeenCalledTimes(1);
});
});
test('renders the component correctly, with one extended component, isDropDown false', () => {
const action = jest.fn();
const pluginState = {
plugins: {
components: {
MobileChannelHeaderButton: [
{
id: 'someid',
pluginId: 'pluginid',
icon: <i className='fa fa-anchor'/>,
action,
dropdownText: 'some dropdown text',
},
],
},
},
};
renderWithContext(
<WithTestMenuContext>
<MobileChannelHeaderPlugins
channel={channel}
isDropdown={false}
/>
</WithTestMenuContext>, pluginState,
);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(action).toHaveBeenCalledTimes(1);
});
test('renders the component correctly, with one extended appbinding, isDropDown false', async () => {
jest.spyOn(appsActions, 'handleBindingClick').mockReturnValueOnce(() => {
return Promise.resolve({
data: {
type: AppCallResponseTypes.OK,
},
});
});
renderWithContext(
<WithTestMenuContext>
<MobileChannelHeaderPlugins
channel={channel}
isDropdown={false}
/>
</WithTestMenuContext>, bindingState,
);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
fireEvent.click(button);
await waitFor(() => {
expect(appsActions.handleBindingClick).toHaveBeenCalledTimes(1);
});
});
test('renders noting if multiple appbindings or components isDropDown false', () => {
const pluginState = {
plugins: {
components: {
MobileChannelHeaderButton: [
{
id: 'someid',
pluginId: 'pluginid',
icon: <i className='fa fa-anchor'/>,
action: jest.fn(),
dropdownText: 'some dropdown text',
},
],
},
},
};
const bothState = {
...bindingState,
...pluginState,
};
renderWithContext(
<WithTestMenuContext>
<MobileChannelHeaderPlugins
channel={channel}
isDropdown={false}
/>
</WithTestMenuContext>, bothState,
);
const button = screen.queryByRole('button');
expect(button).toBeNull();
});
});

Просмотреть файл

@@ -0,0 +1,190 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {useIntl} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import type {AppBinding} from '@mattermost/types/apps';
import type {Channel} from '@mattermost/types/channels';
import {AppCallResponseTypes, AppBindingLocations} from 'mattermost-redux/constants/apps';
import {makeAppBindingsSelector} from 'mattermost-redux/selectors/entities/apps';
import {getMyCurrentChannelMembership} from 'mattermost-redux/selectors/entities/channels';
import {handleBindingClick, openAppsModal, postEphemeralCallResponseForChannel} from 'actions/apps';
import {getChannelMobileHeaderPluginButtons} from 'selectors/plugins';
import * as Menu from 'components/menu';
import {createCallContext} from 'utils/apps';
import type {MobileChannelHeaderButtonAction} from 'types/store/plugins';
type Props = {
channel: Channel;
isDropdown: boolean;
}
const MobileChannelHeaderPlugins = (props: Props): JSX.Element => {
const mobileComponents = useSelector(getChannelMobileHeaderPluginButtons);
const channelMember = useSelector(getMyCurrentChannelMembership);
const getChannelHeaderBindings = useSelector(makeAppBindingsSelector(AppBindingLocations.CHANNEL_HEADER_ICON));
const intl = useIntl();
const dispatch = useDispatch();
const createAppButton = (binding: AppBinding) => {
const handleAppButtonClick = () => fireAppAction(binding);
if (props.isDropdown) {
return (
<Menu.Item
key={'mobileChannelHeaderItem' + binding.app_id + binding.location}
onClick={handleAppButtonClick}
labels={<span>{binding.label}</span>}
/>
);
}
return (
<li className='flex-parent--center'>
<button
id={`${binding.app_id}_${binding.location}`}
className='navbar-toggle navbar-right__icon'
onClick={handleAppButtonClick}
>
<span className='icon navbar-plugin-button'>
<img
alt=''
src={binding.icon}
width='16'
height='16'
/>
</span>
</button>
</li>
);
};
const createButton = (plug: MobileChannelHeaderButtonAction) => {
const handlePluginButtonClick = () => fireAction(plug);
if (props.isDropdown) {
return (
<Menu.Item
key={'mobileChannelHeaderItem' + plug.id}
id={'mobileChannelHeaderItem' + plug.id}
onClick={handlePluginButtonClick}
labels={<span>{plug.dropdownText}</span>}
/>
);
}
return (
<li className='flex-parent--center'>
<button
className='navbar-toggle navbar-right__icon'
onClick={handlePluginButtonClick}
>
<span className='icon navbar-plugin-button'>
{plug.icon}
</span>
</button>
</li>
);
};
const createList = (plugs: MobileChannelHeaderButtonAction[]) => {
return plugs.map(createButton);
};
const createAppList = (bindings: AppBinding[]) => {
return bindings.map(createAppButton);
};
const fireAction = (plug: MobileChannelHeaderButtonAction) => {
return plug.action?.(props.channel, channelMember);
};
const fireAppAction = async (binding: AppBinding) => {
const {channel} = props;
const context = createCallContext(
binding.app_id,
binding.location,
channel.id,
channel.team_id,
);
const handleAppResponse = (callResp: any, errorResponse?: any) => {
if (errorResponse) {
const errorMessage = errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
dispatch(postEphemeralCallResponseForChannel(errorResponse, errorMessage, channel.id));
return;
}
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.text) {
dispatch(postEphemeralCallResponseForChannel(callResp, callResp.text, channel.id));
}
break;
case AppCallResponseTypes.NAVIGATE:
break;
case AppCallResponseTypes.FORM:
if (callResp.form) {
dispatch(openAppsModal(callResp.form, context));
}
break;
default: {
const errorMessage = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
dispatch(postEphemeralCallResponseForChannel(callResp, errorMessage, channel.id));
}
}
};
const res = await dispatch(handleBindingClick(binding, context, intl));
if (res.error) {
handleAppResponse(null, res.error);
return;
}
handleAppResponse(res.data!);
};
const components = mobileComponents || [];
const bindings = getChannelHeaderBindings || [];
if (components.length === 0 && bindings.length === 0) {
return <></>;
} else if (components.length === 1 && bindings.length === 0) {
return createButton(components[0]);
} else if (components.length === 0 && bindings.length === 1) {
return createAppButton(bindings[0]);
}
if (!props.isDropdown) {
return <></>;
}
const plugItems = createList(components);
const appItems = createAppList(bindings);
return (
<>
<Menu.Separator/>
{appItems}
{plugItems}
</>
);
};
// Exported for tests
export {MobileChannelHeaderPlugins as RawMobileChannelHeaderPlug};
export default memo(MobileChannelHeaderPlugins);

Просмотреть файл

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as modalActions from 'actions/views/modals';
import ChannelNotificationsModal from 'components/channel_notifications_modal';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import Notification from './notification';
describe('components/ChannelHeaderMenu/MenuItems/Notification', () => {
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
// Mock useDispatch to return our custom dispatch function
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly, handle click event', () => {
const channel = TestHelper.getChannelMock();
const user = TestHelper.getUserMock();
renderWithContext(
<WithTestMenuContext>
<Notification
channel={channel}
user={user}
/>
</WithTestMenuContext>, {},
);
const menuItemMG = screen.getByText('Notification Preferences');
expect(menuItemMG).toBeInTheDocument();
fireEvent.click(menuItemMG); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.CHANNEL_NOTIFICATIONS,
dialogType: ChannelNotificationsModal,
dialogProps: {
channel,
currentUser: user,
},
});
});
});

Просмотреть файл

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {BellOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import {openModal} from 'actions/views/modals';
import ChannelNotificationsModal from 'components/channel_notifications_modal';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
interface Props extends Menu.FirstMenuItemProps {
channel: Channel;
user: UserProfile;
}
const Notification = ({channel, user, ...rest}: Props): JSX.Element => {
const dispatch = useDispatch();
const handleNotificationPreferences = () => {
dispatch(openModal({
modalId: ModalIdentifiers.CHANNEL_NOTIFICATIONS,
dialogType: ChannelNotificationsModal,
dialogProps: {
channel,
currentUser: user,
},
}));
};
return (
<Menu.Item
leadingElement={<BellOutlineIcon size='18px'/>}
id='channelNotificationPreferences'
onClick={handleNotificationPreferences}
labels={
<FormattedMessage
id='navbar.preferences'
defaultMessage='Notification Preferences'
/>
}
{...rest}
/>
);
};
export default React.memo(Notification);

Просмотреть файл

@@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import * as rhsActions from 'actions/views/rhs';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {RHSStates} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import OpenMembersRHS from './open_members_rhs';
describe('components/ChannelHeaderMenu/MenuItems/OpenMembersRHS', () => {
beforeEach(() => {
// jest.spyOn(rhsActions, 'closeRightHandSide').mockImplementation(() => () => ({data: true}));
jest.spyOn(rhsActions, 'showChannelMembers').mockReturnValue(() => Promise.resolve({data: true}));
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly, handles click event, rhs closed', () => {
const state = {
views: {
rhs: {
rhsState: '',
},
},
};
const channel = TestHelper.getChannelMock();
renderWithContext(
<WithTestMenuContext>
<OpenMembersRHS
channel={channel}
id={'testID'}
text={
<FormattedMessage
id='channel_header.viewMembers'
defaultMessage='View Members'
/>
}
/>
</WithTestMenuContext>, state,
);
const menuItem = screen.getByText('View Members');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(rhsActions.showChannelMembers).toHaveBeenCalledTimes(1);
expect(rhsActions.showChannelMembers).toHaveBeenCalledWith(channel.id, false);
});
test('renders the component correctly, handles correct click event, rhs open', () => {
const state = {
views: {
rhs: {
rhsState: RHSStates.CHANNEL_MEMBERS,
isSidebarOpen: true,
},
},
};
const channel = TestHelper.getChannelMock();
renderWithContext(
<WithTestMenuContext>
<OpenMembersRHS
channel={channel}
id={'testID'}
text={
<FormattedMessage
id='channel_header.viewMembers'
defaultMessage='View Members'
/>
}
/>
</WithTestMenuContext>, state,
);
const menuItem = screen.getByText('View Members');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu
expect(rhsActions.showChannelMembers).not.toHaveBeenCalled();
});
});

Просмотреть файл

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {AccountOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {showChannelMembers} from 'actions/views/rhs';
import {getIsRhsOpen, getRhsState} from 'selectors/rhs';
import * as Menu from 'components/menu';
import {RHSStates} from 'utils/constants';
type Props = {
channel: Channel;
id: string;
editMembers?: boolean;
text: React.ReactElement;
};
const OpenMembersRHS = ({
id,
channel,
text,
editMembers = false,
}: Props) => {
const dispatch = useDispatch();
let rhsOpen = useSelector(getIsRhsOpen);
const rhsState = useSelector(getRhsState);
if (rhsState !== RHSStates.CHANNEL_MEMBERS) {
rhsOpen = false;
}
const openRHSIfNotOpen = () => {
if (rhsOpen) {
return;
}
dispatch(showChannelMembers(channel.id, editMembers));
};
return (
<Menu.Item
leadingElement={<AccountOutlineIcon size={16}/>}
id={id}
onClick={openRHSIfNotOpen}
labels={text}
/>
);
};
export default OpenMembersRHS;

Просмотреть файл

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ReactNode} from 'react';
import React, {memo} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {
AppsIcon,
ChevronRightIcon,
} from '@mattermost/compass-icons/components';
import * as Menu from 'components/menu';
type Props = {
pluginItems: ReactNode[];
};
const PluginsSubmenu = (props: Props) => {
const {formatMessage} = useIntl();
if (!props.pluginItems || !props.pluginItems.length) {
return <></>;
}
return (
<Menu.SubMenu
id={'moreActions'}
labels={
<FormattedMessage
id='pluginsMenu.more_actions'
defaultMessage='More actions'
/>
}
leadingElement={<AppsIcon size='18px'/>}
trailingElements={<ChevronRightIcon size={16}/>}
menuId={'moreActions-menu'}
menuAriaLabel={formatMessage({id: 'pluginsMenu.more_actions', defaultMessage: 'More actions'})}
>
{props.pluginItems}
</Menu.SubMenu>
);
};
export default memo(PluginsSubmenu);

Просмотреть файл

@@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as channelActions from 'mattermost-redux/actions/channels';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import ToggleFavoriteChannel from './toggle_favorite_channel';
describe('components/ChannelHeaderMenu/MenuItems/ToggleFavoriteChannel', () => {
const channel = TestHelper.getChannelMock();
beforeEach(() => {
jest.spyOn(channelActions, 'favoriteChannel').mockReturnValue(() => Promise.resolve({data: true}));
jest.spyOn(channelActions, 'unfavoriteChannel');
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly, handles correct click event, is favorite false', () => {
renderWithContext(
<WithTestMenuContext>
<ToggleFavoriteChannel
channelID={channel.id}
isFavorite={false}
/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Add to Favorites');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
// expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(channelActions.favoriteChannel).toHaveBeenCalledTimes(1);
expect(channelActions.favoriteChannel).toHaveBeenCalledWith(channel.id);
});
test('renders the component correctly, handles correct click event, is favorite true', () => {
renderWithContext(
<WithTestMenuContext>
<ToggleFavoriteChannel
channelID={channel.id}
isFavorite={true}
/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Remove from Favorites');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(channelActions.unfavoriteChannel).toHaveBeenCalledTimes(1);
});
});

Просмотреть файл

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {favoriteChannel, unfavoriteChannel} from 'mattermost-redux/actions/channels';
import * as Menu from 'components/menu';
type Props = {
channelID: string;
isFavorite: boolean;
};
const ToggleFavoriteChannel = ({
isFavorite,
channelID,
}: Props) => {
const dispatch = useDispatch();
const toggleFavorite = () => {
if (isFavorite) {
dispatch(unfavoriteChannel(channelID));
} else {
dispatch(favoriteChannel(channelID));
}
};
let text = (
<FormattedMessage
id='channelHeader.addToFavorites'
defaultMessage='Add to Favorites'
/>);
if (isFavorite) {
text = (
<FormattedMessage
id='channelHeader.removeFromFavorites'
defaultMessage='Remove from Favorites'
/>);
}
return (
<Menu.Item
onClick={toggleFavorite}
labels={text}
/>
);
};
export default memo(ToggleFavoriteChannel);

Просмотреть файл

@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as rhsActions from 'actions/views/rhs';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {RHSStates} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import ToggleInfo from './toggle_info';
describe('components/ChannelHeaderMenu/MenuItems/ToggleInfo', () => {
beforeEach(() => {
jest.spyOn(rhsActions, 'closeRightHandSide').mockImplementation(() => () => ({data: true}));
jest.spyOn(rhsActions, 'showChannelInfo');
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly, handles click event, rhs closed', () => {
const state = {
views: {
rhs: {
rhsState: '',
},
},
};
const channel = TestHelper.getChannelMock();
renderWithContext(
<WithTestMenuContext>
<ToggleInfo channel={channel}/>
</WithTestMenuContext>, state,
);
const menuItem = screen.getByText('View Info');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
// expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(rhsActions.showChannelInfo).toHaveBeenCalledTimes(1);
expect(rhsActions.showChannelInfo).toHaveBeenCalledWith(channel.id);
});
test('renders the component correctly, handles correct click event, rhs open', () => {
const state = {
views: {
rhs: {
rhsState: RHSStates.CHANNEL_INFO,
isSidebarOpen: true,
},
},
};
const channel = TestHelper.getChannelMock();
renderWithContext(
<WithTestMenuContext>
<ToggleInfo channel={channel}/>
</WithTestMenuContext>, state,
);
const menuItem = screen.getByText('Close Info');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(rhsActions.closeRightHandSide).toHaveBeenCalledTimes(1);
});
});

Просмотреть файл

@@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import {InformationOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {closeRightHandSide, showChannelInfo} from 'actions/views/rhs';
import {getIsRhsOpen, getRhsState} from 'selectors/rhs';
import * as Menu from 'components/menu';
import {RHSStates} from 'utils/constants';
interface Props extends Menu.FirstMenuItemProps {
channel: Channel;
}
const ToggleInfo = ({channel, ...rest}: Props) => {
const dispatch = useDispatch();
let rhsOpen = useSelector(getIsRhsOpen);
const rhsState = useSelector(getRhsState);
if (rhsState !== RHSStates.CHANNEL_INFO) {
rhsOpen = false;
}
const toggleRHS = () => {
if (rhsOpen) {
dispatch(closeRightHandSide());
return;
}
dispatch(showChannelInfo(channel.id));
};
let text;
if (rhsOpen) {
text = (
<FormattedMessage
id='channelHeader.hideInfo'
defaultMessage='Close Info'
/>);
} else {
text = (
<FormattedMessage
id='channelHeader.viewInfo'
defaultMessage='View Info'
/>);
}
return (
<>
<Menu.Item
leadingElement={<InformationOutlineIcon size='18px'/>}
onClick={toggleRHS}
labels={text}
{...rest}
/>
</>
);
};
export default ToggleInfo;

Просмотреть файл

@@ -0,0 +1,126 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as channelActions from 'mattermost-redux/actions/channels';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {NotificationLevels} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import ToggleMuteChannel from './toggle_mute_channel';
describe('components/ChannelHeaderMenu/MenuItems/ToggleMuteChannel', () => {
const channel = TestHelper.getChannelMock();
const user = TestHelper.getUserMock();
beforeEach(() => {
jest.spyOn(channelActions, 'updateChannelNotifyProps').mockReturnValue(() => Promise.resolve({data: true}));
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly, public channel, not muted', () => {
renderWithContext(
<WithTestMenuContext>
<ToggleMuteChannel
channel={channel}
userID={user.id}
isMuted={false}
/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Mute Channel');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(channelActions.updateChannelNotifyProps).toHaveBeenCalledTimes(1);
expect(channelActions.updateChannelNotifyProps).toHaveBeenCalledWith(
user.id,
channel.id,
{mark_unread: NotificationLevels.MENTION},
);
});
test('renders the component correctly, public channel, muted', () => {
renderWithContext(
<WithTestMenuContext>
<ToggleMuteChannel
channel={channel}
userID={user.id}
isMuted={true}
/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Unmute Channel');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(channelActions.updateChannelNotifyProps).toHaveBeenCalledTimes(1);
expect(channelActions.updateChannelNotifyProps).toHaveBeenCalledWith(
user.id,
channel.id,
{mark_unread: NotificationLevels.ALL},
);
});
test('renders the component correctly, dm channel, not muted', () => {
const channel = TestHelper.getChannelMock({type: 'D'});
renderWithContext(
<WithTestMenuContext>
<ToggleMuteChannel
channel={channel}
userID={user.id}
isMuted={false}
/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Mute');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(channelActions.updateChannelNotifyProps).toHaveBeenCalledTimes(1);
expect(channelActions.updateChannelNotifyProps).toHaveBeenCalledWith(
user.id,
channel.id,
{mark_unread: NotificationLevels.MENTION},
);
});
test('renders the component correctly, dm channel, muted', () => {
const channel = TestHelper.getChannelMock({type: 'D'});
renderWithContext(
<WithTestMenuContext>
<ToggleMuteChannel
channel={channel}
userID={user.id}
isMuted={true}
/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Unmute');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(channelActions.updateChannelNotifyProps).toHaveBeenCalledTimes(1);
expect(channelActions.updateChannelNotifyProps).toHaveBeenCalledWith(
user.id,
channel.id,
{mark_unread: NotificationLevels.ALL},
);
});
});

Просмотреть файл

@@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {BellOffOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {updateChannelNotifyProps} from 'mattermost-redux/actions/channels';
import * as Menu from 'components/menu';
import {Constants, NotificationLevels} from 'utils/constants';
type Props = {
userID: string;
channel: Channel;
isMuted: boolean;
};
export default function ToggleMuteChannel({
isMuted,
channel,
userID,
}: Props) {
const dispatch = useDispatch();
const handleClick = () => {
dispatch(updateChannelNotifyProps(
userID,
channel.id,
{
mark_unread: (isMuted ? NotificationLevels.ALL : NotificationLevels.MENTION) as 'all' | 'mention',
},
));
};
let text;
if (channel.type === Constants.DM_CHANNEL || channel.type === Constants.GM_CHANNEL) {
if (isMuted) {
text = (
<FormattedMessage
id='channel_header.unmuteConversation'
defaultMessage='Unmute'
/>
);
} else {
text = (
<FormattedMessage
id='channel_header.muteConversation'
defaultMessage='Mute'
/>
);
}
} else if (isMuted) {
text = (
<FormattedMessage
id='channel_header.unmute'
defaultMessage='Unmute Channel'
/>
);
} else {
text = (
<FormattedMessage
id='channel_header.mute'
defaultMessage='Mute Channel'
/>
);
}
return (
<Menu.Item
leadingElement={<BellOffOutlineIcon size='18px'/>}
id='channelToggleMuteChannel'
onClick={handleClick}
labels={text}
/>
);
}

Просмотреть файл

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as modalActions from 'actions/views/modals';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import UnarchiveChannelModal from 'components/unarchive_channel_modal';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import UnarchiveChannel from './unarchive_channel';
describe('components/ChannelHeaderMenu/MenuItems/UnarchiveChannel', () => {
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly, handle click event', () => {
const channel = TestHelper.getChannelMock();
renderWithContext(
<WithTestMenuContext>
<UnarchiveChannel channel={channel}/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Unarchive Channel');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.UNARCHIVE_CHANNEL,
dialogType: UnarchiveChannelModal,
dialogProps: {channel},
});
});
});

Просмотреть файл

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import {openModal} from 'actions/views/modals';
import * as Menu from 'components/menu';
import UnarchiveChannelModal from 'components/unarchive_channel_modal';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
channel: Channel;
}
const UnarchiveChannel = ({
channel,
}: Props) => {
const dispatch = useDispatch();
const handleUnarchiveChannel = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.UNARCHIVE_CHANNEL,
dialogType: UnarchiveChannelModal,
dialogProps: {channel},
}),
);
};
return (
<>
<Menu.Separator/>
<Menu.Item
id='channelUnarchiveChannel'
onClick={handleUnarchiveChannel}
labels={
<FormattedMessage
id='channel_header.unarchive'
defaultMessage='Unarchive Channel'
/>
}
/>
</>
);
};
export default memo(UnarchiveChannel);

Просмотреть файл

@@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as rhsActions from 'actions/views/rhs';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {RHSStates} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import ViewPinnedPosts from './view_pinned_posts';
describe('components/ChannelHeaderMenu/MenuItems/ViewPinnedPosts', () => {
beforeEach(() => {
jest.spyOn(rhsActions, 'closeRightHandSide').mockImplementation(() => () => ({data: true}));
jest.spyOn(rhsActions, 'showPinnedPosts').mockReturnValue(() => Promise.resolve({data: true}));
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly, handles correct click event', () => {
const state = {
views: {
rhs: {
rhsState: '',
},
},
};
const channel = TestHelper.getChannelMock();
renderWithContext(
<WithTestMenuContext>
<ViewPinnedPosts channelID={channel.id}/>
</WithTestMenuContext>, state,
);
const menuItem = screen.getByText('View Pinned Posts');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(rhsActions.showPinnedPosts).toHaveBeenCalledTimes(1);
expect(rhsActions.showPinnedPosts).toHaveBeenCalledWith(channel.id);
});
test('renders the component correctly, handles correct click event', () => {
const state = {
views: {
rhs: {
rhsState: RHSStates.PIN,
},
},
};
const channel = TestHelper.getChannelMock();
renderWithContext(
<WithTestMenuContext>
<ViewPinnedPosts channelID={channel.id}/>
</WithTestMenuContext>, state,
);
const menuItem = screen.getByText('View Pinned Posts');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(rhsActions.closeRightHandSide).toHaveBeenCalledTimes(1);
});
});

Просмотреть файл

@@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {closeRightHandSide, showPinnedPosts} from 'actions/views/rhs';
import {getRhsState} from 'selectors/rhs';
import * as Menu from 'components/menu';
import {RHSStates} from 'utils/constants';
type Props = {
channelID: string;
}
const ViewPinnedPosts = ({
channelID,
}: Props) => {
const dispatch = useDispatch();
const rhsState = useSelector(getRhsState);
const hasPinnedPosts = rhsState === RHSStates.PIN;
const handleClick = () => {
if (hasPinnedPosts) {
dispatch(closeRightHandSide());
} else {
dispatch(showPinnedPosts(channelID));
}
};
return (
<Menu.Item
onClick={handleClick}
labels={
<FormattedMessage
id='navbar.viewPinnedPosts'
defaultMessage='View Pinned Posts'
/>
}
/>
);
};
export default memo(ViewPinnedPosts);

Просмотреть файл

@@ -1,285 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/ChannelHeaderMobile/ChannelHeaderMobile should match snapshot 1`] = `
<nav
className="navbar navbar-default navbar-fixed-top"
id="navbar"
role="navigation"
>
<div
className="container-fluid theme"
>
<div
className="navbar-header"
>
<Connect(CollapseLhsButton) />
<div
className="navbar-brand"
>
<Connect(Component) />
</div>
<div
className="spacer"
/>
<Connect(NavbarInfoButton)
channel={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "display_name",
"group_constrained": false,
"header": "header",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "DN",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "O",
"update_at": 0,
}
}
/>
<Connect(Component) />
<Connect(injectIntl(MobileChannelHeaderPlug))
channel={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "display_name",
"group_constrained": false,
"header": "header",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "DN",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "O",
"update_at": 0,
}
}
isDropdown={false}
/>
<Connect(CollapseRhsButton) />
</div>
</div>
</nav>
`;
exports[`components/ChannelHeaderMobile/ChannelHeaderMobile should match snapshot, for default channel 1`] = `
<nav
className="navbar navbar-default navbar-fixed-top"
id="navbar"
role="navigation"
>
<div
className="container-fluid theme"
>
<div
className="navbar-header"
>
<Connect(CollapseLhsButton) />
<div
className="navbar-brand"
>
<Connect(Component) />
</div>
<div
className="spacer"
/>
<Connect(NavbarInfoButton)
channel={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "Town Square",
"group_constrained": false,
"header": "header",
"id": "123",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "town-square",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "O",
"update_at": 0,
}
}
/>
<Connect(Component) />
<Connect(injectIntl(MobileChannelHeaderPlug))
channel={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "Town Square",
"group_constrained": false,
"header": "header",
"id": "123",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "town-square",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "O",
"update_at": 0,
}
}
isDropdown={false}
/>
<Connect(CollapseRhsButton) />
</div>
</div>
</nav>
`;
exports[`components/ChannelHeaderMobile/ChannelHeaderMobile should match snapshot, for private channel 1`] = `
<nav
className="navbar navbar-default navbar-fixed-top"
id="navbar"
role="navigation"
>
<div
className="container-fluid theme"
>
<div
className="navbar-header"
>
<Connect(CollapseLhsButton) />
<div
className="navbar-brand"
>
<Connect(Component) />
</div>
<div
className="spacer"
/>
<Connect(NavbarInfoButton)
channel={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "display_name",
"group_constrained": false,
"header": "header",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "DN",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "P",
"update_at": 0,
}
}
/>
<Connect(Component) />
<Connect(injectIntl(MobileChannelHeaderPlug))
channel={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "display_name",
"group_constrained": false,
"header": "header",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "DN",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "P",
"update_at": 0,
}
}
isDropdown={false}
/>
<Connect(CollapseRhsButton) />
</div>
</div>
</nav>
`;
exports[`components/ChannelHeaderMobile/ChannelHeaderMobile should match snapshot, if DM channel 1`] = `
<nav
className="navbar navbar-default navbar-fixed-top"
id="navbar"
role="navigation"
>
<div
className="container-fluid theme"
>
<div
className="navbar-header"
>
<Connect(CollapseLhsButton) />
<div
className="navbar-brand"
>
<Connect(Component) />
</div>
<div
className="spacer"
/>
<Connect(NavbarInfoButton)
channel={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "display_name",
"group_constrained": false,
"header": "header",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "user_id_1__user_id_2",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "D",
"update_at": 0,
}
}
/>
<Connect(Component) />
<Connect(injectIntl(MobileChannelHeaderPlug))
channel={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "display_name",
"group_constrained": false,
"header": "header",
"id": "channel_id",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "user_id_1__user_id_2",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "D",
"update_at": 0,
}
}
isDropdown={false}
/>
<Connect(CollapseRhsButton) />
</div>
</div>
</nav>
`;

Просмотреть файл

@@ -1,9 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import ChannelHeaderMobile from './channel_header_mobile';
@@ -14,86 +14,91 @@ describe('components/ChannelHeaderMobile/ChannelHeaderMobile', () => {
removeEventListener: jest.fn(),
});
const baseProps = {
user: TestHelper.getUserMock({
id: 'user_id',
}),
channel: TestHelper.getChannelMock({
type: 'O',
id: 'channel_id',
display_name: 'display_name',
team_id: 'team_id',
}),
member: TestHelper.getChannelMembershipMock({
channel_id: 'channel_id',
user_id: 'user_id',
}),
teamDisplayName: 'team_display_name',
isPinnedPosts: true,
actions: {
closeLhs: jest.fn(),
closeRhs: jest.fn(),
closeRhsMenu: jest.fn(),
},
isLicensed: true,
isMobileView: false,
isFavoriteChannel: false,
const user = TestHelper.getUserMock({
id: 'user_id',
});
const channel = TestHelper.getChannelMock({
type: 'O',
id: 'channel_id',
display_name: 'display_name',
team_id: 'team_id',
});
const actions = {
closeLhs: jest.fn(),
closeRhs: jest.fn(),
closeRhsMenu: jest.fn(),
};
test('should match snapshot', () => {
const wrapper = shallow(
<ChannelHeaderMobile {...baseProps}/>,
);
describe('components/ChannelHeaderMenu/MenuItem/ChannelHeaderMobile', () => {
test('renders the component correctly', () => {
renderWithContext(
<div
className='inner-wrap'
data-testid='wrapper'
>
<ChannelHeaderMobile
channel={channel}
isMobileView={false}
user={user}
actions={actions}
/>
</div>,
);
expect(wrapper).toMatchSnapshot();
});
let menuItem = screen.getByText('Toggle sidebar');
expect(menuItem).toBeInTheDocument();
test('should match snapshot, for default channel', () => {
const props = {
...baseProps,
channel: TestHelper.getChannelMock({
type: 'O',
id: '123',
name: 'town-square',
display_name: 'Town Square',
team_id: 'team_id',
}),
};
const wrapper = shallow(
<ChannelHeaderMobile {...props}/>,
);
menuItem = screen.getByLabelText('Info');
expect(menuItem).toBeInTheDocument();
expect(wrapper).toMatchSnapshot();
});
menuItem = screen.getByLabelText('Search');
expect(menuItem).toBeInTheDocument();
test('should match snapshot, if DM channel', () => {
const props = {
...baseProps,
channel: TestHelper.getChannelMock({
type: 'D',
id: 'channel_id',
name: 'user_id_1__user_id_2',
display_name: 'display_name',
team_id: 'team_id',
}),
};
const wrapper = shallow(<ChannelHeaderMobile {...props}/>);
menuItem = screen.getByText('Toggle right sidebar');
expect(menuItem).toBeInTheDocument();
expect(wrapper).toMatchSnapshot();
});
const wrapper = screen.getByTestId('wrapper');
expect(wrapper).toBeInTheDocument();
});
test('should match snapshot, for private channel', () => {
const props = {
...baseProps,
channel: TestHelper.getChannelMock({
type: 'P',
id: 'channel_id',
display_name: 'display_name',
team_id: 'team_id',
}),
};
const wrapper = shallow(<ChannelHeaderMobile {...props}/>);
test('renders the component correctly, global threads', () => {
renderWithContext(
<div
className='inner-wrap'
data-testid='wrapper'
>
<ChannelHeaderMobile
channel={channel}
isMobileView={false}
inGlobalThreads={true}
user={user}
actions={actions}
/>
</div>,
);
expect(wrapper).toMatchSnapshot();
const menuItem = screen.getByText('Followed threads');
expect(menuItem).toBeInTheDocument();
});
test('renders the component correctly, in drafts', () => {
renderWithContext(
<div
className='inner-wrap'
data-testid='wrapper'
>
<ChannelHeaderMobile
channel={channel}
isMobileView={false}
inDrafts={true}
user={user}
actions={actions}
/>
</div>,
);
const menuItem = screen.getByText('Drafts');
expect(menuItem).toBeInTheDocument();
});
});
});

Просмотреть файл

@@ -8,29 +8,22 @@ import {FormattedMessage} from 'react-intl';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile} from '@mattermost/types/users';
import {MobileChannelHeaderDropdown} from 'components/channel_header_dropdown';
import MobileChannelHeaderPlug from 'plugins/mobile_channel_header_plug';
import ChannelInfoButton from './channel_info_button';
import CollapseLhsButton from './collapse_lhs_button';
import CollapseRhsButton from './collapse_rhs_button';
import ShowSearchButton from './show_search_button';
import UnmuteChannelButton from './unmute_channel_button';
import ChannelHeaderMenu from '../channel_header_menu/channel_header_menu';
import MobileChannelHeaderPlugins from '../channel_header_menu/menu_items/mobile_channel_header_plugins';
type Props = {
channel?: Channel;
/**
* Relative url for the team, used to redirect if a link in the channel header is clicked
*/
currentRelativeTeamUrl?: string;
inGlobalThreads?: boolean;
inDrafts?: boolean;
isMobileView: boolean;
isMuted?: boolean;
isReadOnly?: boolean;
isRHSOpen?: boolean;
user: UserProfile;
actions: {
@@ -85,7 +78,10 @@ export default class ChannelHeaderMobile extends React.PureComponent<Props> {
} else if (channel) {
heading = (
<>
<MobileChannelHeaderDropdown/>
<ChannelHeaderMenu
isMobile={true}
/>
{isMuted && (
<UnmuteChannelButton
user={user}
@@ -114,13 +110,13 @@ export default class ChannelHeaderMobile extends React.PureComponent<Props> {
channel={channel}
/>
)}
<ShowSearchButton/>
{channel && (
<MobileChannelHeaderPlug
<MobileChannelHeaderPlugins
channel={channel}
isDropdown={false}
/>
)}
<ShowSearchButton/>
<CollapseRhsButton/>
</div>
</div>

Просмотреть файл

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import MenuIcon from 'components/widgets/icons/menu_icon';
@@ -22,6 +23,12 @@ const CollapseRhsButton: React.FunctionComponent<Props> = (props: Props) => (
data-target='#sidebar-nav'
onClick={props.actions.toggleRhsMenu}
>
<span className='sr-only'>
<FormattedMessage
id='navbar.toggle3'
defaultMessage='Toggle right sidebar'
/>
</span>
<MenuIcon/>
</button>
);

Просмотреть файл

@@ -62,7 +62,7 @@ const ConvertGmToChannelModal = (props: Props) => {
map((user) => displayUsername(user, props.teammateNameDisplaySetting));
setChannelMemberNames(validProfilesInChannel);
}, [props.profilesInChannel]);
}, [props.profilesInChannel, props.currentUserId, props.teammateNameDisplaySetting]);
const [commonTeamsById, setCommonTeamsById] = useState<{[id: string]: Team}>({});
const [commonTeamsFetched, setCommonTeamsFetched] = useState<boolean>(false);
@@ -109,7 +109,7 @@ const ConvertGmToChannelModal = (props: Props) => {
work();
setTimeout(() => setLoadingAnimationTimeout(true), 1200);
}, []);
}, [dispatch, props.channel.id]);
const handleConfirm = useCallback(async () => {
if (!selectedTeamId) {
@@ -136,7 +136,7 @@ const ConvertGmToChannelModal = (props: Props) => {
setConversionError(undefined);
trackEvent('actions', 'convert_group_message_to_private_channel', {channel_id: props.channel.id});
props.onExited();
}, [selectedTeamId, props.channel.id, channelName, channelURL.current, props.actions.moveChannelsInSidebar]);
}, [selectedTeamId, props, channelName, formatMessage]);
const showLoader = !commonTeamsFetched || !loadingAnimationTimeout;
const canCreate = selectedTeamId !== undefined && channelName !== '' && !nameError && !urlError;

Просмотреть файл

@@ -5,6 +5,8 @@ import {connect} from 'react-redux';
import type {Dispatch} from 'redux';
import {bindActionCreators} from 'redux';
import type {Channel} from '@mattermost/types/channels';
import {convertGroupMessageToPrivateChannel} from 'mattermost-redux/actions/channels';
import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
import {
@@ -16,15 +18,17 @@ import type {ActionResult} from 'mattermost-redux/types/actions';
import {moveChannelsInSidebar} from 'actions/views/channel_sidebar';
import {closeModal} from 'actions/views/modals';
import type {Props} from 'components/convert_gm_to_channel_modal/convert_gm_to_channel_modal';
import ConvertGmToChannelModal from 'components/convert_gm_to_channel_modal/convert_gm_to_channel_modal';
import type {GlobalState} from 'types/store';
import ConvertGmToChannelModal from './convert_gm_to_channel_modal';
type OwnProps = {
channel: Channel;
}
function makeMapStateToProps() {
const getProfilesInChannel = makeGetProfilesInChannel();
return (state: GlobalState, ownProps: Props) => {
return (state: GlobalState, ownProps: OwnProps) => {
const allProfilesInChannel = getProfilesInChannel(state, ownProps.channel.id);
const currentUserId = getCurrentUserId(state);
const teammateNameDisplaySetting = getTeammateNameDisplaySetting(state);

Просмотреть файл

@@ -495,8 +495,8 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_menu should show cor
id="mute-channel_id"
labels={
<Memo(MemoizedFormattedMessage)
defaultMessage="Mute Conversation"
id="sidebar_left.sidebar_channel_menu.muteConversation"
defaultMessage="Mute"
id="sidebar_left.sidebar_channel_menu.mute"
/>
}
leadingElement={

Просмотреть файл

@@ -148,8 +148,8 @@ const SidebarChannelMenu = ({
if (channel.type === Constants.DM_CHANNEL || channel.type === Constants.GM_CHANNEL) {
muteChannelText = (
<FormattedMessage
id='sidebar_left.sidebar_channel_menu.unmuteConversation'
defaultMessage='Unmute Conversation'
id='sidebar_left.sidebar_channel_menu.unmute'
defaultMessage='Unmute'
/>
);
}
@@ -176,8 +176,8 @@ const SidebarChannelMenu = ({
if (channel.type === Constants.DM_CHANNEL || channel.type === Constants.GM_CHANNEL) {
muteChannelText = (
<FormattedMessage
id='sidebar_left.sidebar_channel_menu.muteConversation'
defaultMessage='Mute Conversation'
id='sidebar_left.sidebar_channel_menu.mute'
defaultMessage='Mute'
/>
);
}

Просмотреть файл

@@ -3230,22 +3230,22 @@
"channel_header.lastActive": "Last online {timestamp}",
"channel_header.lastOnline": "Last online {timestamp}",
"channel_header.leave": "Leave Channel",
"channel_header.manageMembers": "Manage Members",
"channel_header.menuAriaLabel": "Channel Menu",
"channel_header.members": "Members",
"channel_header.mute": "Mute Channel",
"channel_header.muteConversation": "Mute Conversation",
"channel_header.muteConversation": "Mute",
"channel_header.openChannelInfo": "View Info",
"channel_header.otherchannel": "{displayName} Channel Menu",
"channel_header.pinnedPosts": "Pinned messages",
"channel_header.recentMentions": "Recent mentions",
"channel_header.rename": "Rename Channel",
"channel_header.search": "Search",
"channel_header.setConversationHeader": "Edit Conversation Header",
"channel_header.setConversationHeader": "Edit Header",
"channel_header.setHeader": "Edit Channel Header",
"channel_header.setPurpose": "Edit Channel Purpose",
"channel_header.settings": "Settings",
"channel_header.unarchive": "Unarchive Channel",
"channel_header.unmute": "Unmute Channel",
"channel_header.unmuteConversation": "Unmute Conversation",
"channel_header.unmuteConversation": "Unmute",
"channel_header.userHelpGuide": "Help",
"channel_header.viewMembers": "View Members",
"channel_info_rhs.about_area_id": "ID:",
@@ -3330,6 +3330,9 @@
"channel_members_rhs.member.send_message": "Send message",
"channel_members_rhs.search_bar.aria.cancel_search_button": "cancel members search",
"channel_members_rhs.search_bar.placeholder": "Search members",
"channel_menu.bookmarks": "Bookmarks Bar",
"channel_menu.bookmarks.addFile": "Attach a file",
"channel_menu.bookmarks.addLink": "Add a link",
"channel_modal.alreadyExist": "A channel with that URL already exists",
"channel_modal.cancel": "Cancel",
"channel_modal.create_board.tooltip_description": "Use any of our templates to manage your tasks or start from scratch with your own!",
@@ -3390,6 +3393,7 @@
"channelNotifications.mobileNotification.newMessages": "All new messages {optionalDefault}",
"channelNotifications.mobileNotification.nothing": "Nothing {optionalDefault}",
"channelSelectorModal.title": "Add Channels to <b>Channel Selection</b> List",
"channelSettings": "Channel Settings",
"channelView.archivedChannel": "You are viewing an <b>archived channel</b>. New messages cannot be posted.",
"channelView.archivedChannelWithDeactivatedUser": "You are viewing an archived channel with a <b>deactivated user</b>. New messages cannot be posted.",
"channelView.login.successfull": "Login Successful",
@@ -4499,6 +4503,7 @@
"navbar.addMembers": "Add Members",
"navbar.preferences": "Notification Preferences",
"navbar.toggle2": "Toggle sidebar",
"navbar.toggle3": "Toggle right sidebar",
"navbar.viewPinnedPosts": "View Pinned Posts",
"newChannelWithBoard.tutorialTip.description": "The board you just created can be quickly accessed by clicking on the Boards icon in the App bar. You can view the boards that are linked to this channel in the right-hand sidebar and open one in full view.",
"newChannelWithBoard.tutorialTip.title": "Access linked boards from the App Bar",
@@ -4662,6 +4667,7 @@
"plan.self_serve": "Self-serve",
"pluggable.errorOccurred": "An error occurred in the {pluginId} plugin.",
"pluggable.errorRefresh": "Refresh?",
"pluginsMenu.more_actions": "More actions",
"post_body.check_for_out_of_channel_groups_mentions.message": "did not get notified by this mention because they are not in the channel. They cannot be added to the channel because they are not a member of the linked groups. To add them to this channel, they must be added to the linked groups.",
"post_body.check_for_out_of_channel_mentions.link.and": " and ",
"post_body.check_for_out_of_channel_mentions.link.private": "add them to this private channel",
@@ -5183,7 +5189,6 @@
"sidebar_left.sidebar_category.newLabel": "new",
"sidebar_left.sidebar_channel_menu_convert_to_channel": "Convert to Private Channel",
"sidebar_left.sidebar_channel_menu.addMembers": "Add Members",
"sidebar_left.sidebar_channel_menu.bookmarks": "Bookmarks Bar",
"sidebar_left.sidebar_channel_menu.channels": "Channels",
"sidebar_left.sidebar_channel_menu.copyLink": "Copy Link",
"sidebar_left.sidebar_channel_menu.dropdownAriaLabel": "Edit channel menu",
@@ -5198,11 +5203,11 @@
"sidebar_left.sidebar_channel_menu.moveTo": "Move to...",
"sidebar_left.sidebar_channel_menu.moveTo.dropdownAriaLabel": "Move to submenu",
"sidebar_left.sidebar_channel_menu.moveToNewCategory": "New Category",
"sidebar_left.sidebar_channel_menu.mute": "Mute",
"sidebar_left.sidebar_channel_menu.muteChannel": "Mute Channel",
"sidebar_left.sidebar_channel_menu.muteConversation": "Mute Conversation",
"sidebar_left.sidebar_channel_menu.unfavoriteChannel": "Unfavorite",
"sidebar_left.sidebar_channel_menu.unmute": "Unmute",
"sidebar_left.sidebar_channel_menu.unmuteChannel": "Unmute Channel",
"sidebar_left.sidebar_channel_menu.unmuteConversation": "Unmute Conversation",
"sidebar_left.sidebar_channel_modal.channel_name_placeholder": "Enter a name for the channel",
"sidebar_left.sidebar_channel_modal.confirmation_text": "Convert to private channel",
"sidebar_left.sidebar_channel_modal.header": "Convert to Private Channel",

Просмотреть файл

@@ -1234,6 +1234,11 @@ export const getMyFirstChannelForTeams: (state: GlobalState) => RelationOneToOne
},
);
export const getRedirectChannelNameForCurrentTeam = (state: GlobalState): string => {
const currentTeamId = getCurrentTeamId(state);
return getRedirectChannelNameForTeam(state, currentTeamId);
};
export const getRedirectChannelNameForTeam = (state: GlobalState, teamId: string): string => {
const defaultChannelForTeam = getDefaultChannelForTeams(state)[teamId];
const canIJoinPublicChannelsInTeam = haveITeamPermission(state,

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -1,42 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {AppBindingLocations} from 'mattermost-redux/constants/apps';
import {appsEnabled, makeAppBindingsSelector} from 'mattermost-redux/selectors/entities/apps';
import {getMyCurrentChannelMembership} from 'mattermost-redux/selectors/entities/channels';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {handleBindingClick, openAppsModal, postEphemeralCallResponseForChannel} from 'actions/apps';
import type {GlobalState} from 'types/store';
import MobileChannelHeaderPlug from './mobile_channel_header_plug';
const getChannelHeaderBindings = makeAppBindingsSelector(AppBindingLocations.CHANNEL_HEADER_ICON);
function mapStateToProps(state: GlobalState) {
const apps = appsEnabled(state);
return {
appBindings: getChannelHeaderBindings(state),
appsEnabled: apps,
channelMember: getMyCurrentChannelMembership(state),
components: state.plugins.components.MobileChannelHeaderButton,
theme: getTheme(state),
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
handleBindingClick,
postEphemeralCallResponseForChannel,
openAppsModal,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(MobileChannelHeaderPlug);

Просмотреть файл

@@ -1,473 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {mount} from 'enzyme';
import React from 'react';
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import {AppCallResponseTypes} from 'mattermost-redux/constants/apps';
import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
import MobileChannelHeaderPlug, {RawMobileChannelHeaderPlug} from 'plugins/mobile_channel_header_plug/mobile_channel_header_plug';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import {createCallContext} from 'utils/apps';
describe('plugins/MobileChannelHeaderPlug', () => {
const testPlug = {
id: 'someid',
pluginId: 'pluginid',
icon: <i className='fa fa-anchor'/>,
action: jest.fn(),
dropdownText: 'some dropdown text',
};
const testBinding = {
app_id: 'appid',
location: 'test',
icon: 'http://test.com/icon.png',
label: 'Label',
hint: 'Hint',
form: {
submit: {
path: '/call/path',
},
},
};
const testChannel = {} as Channel;
const testChannelMember = {} as ChannelMembership;
const testTheme = {} as Theme;
const intl = {
formatMessage: (message: {id: string; defaultMessage: string}) => {
return message.defaultMessage;
},
} as any;
test('should match snapshot with no extended component', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with one extended component', () => {
const wrapper = mount<RawMobileChannelHeaderPlug>(
<RawMobileChannelHeaderPlug
components={[testPlug]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
intl={intl}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a single list item containing a button
expect(wrapper.find('li')).toHaveLength(1);
expect(wrapper.find('button')).toHaveLength(1);
wrapper.instance().fireAction = jest.fn();
wrapper.find('button').first().simulate('click');
expect(wrapper.instance().fireAction).toHaveBeenCalledTimes(1);
expect(wrapper.instance().fireAction).toBeCalledWith(testPlug);
});
test('should match snapshot with two extended components', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[testPlug, {...testPlug, id: 'someid2'}]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with no bindings', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={true}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with one binding', () => {
const wrapper = mount<RawMobileChannelHeaderPlug>(
<RawMobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={true}
appBindings={[testBinding]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
intl={intl}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a single list item containing a button
expect(wrapper.find('li')).toHaveLength(1);
expect(wrapper.find('button')).toHaveLength(1);
wrapper.instance().fireAppAction = jest.fn();
wrapper.find('button').first().simulate('click');
expect(wrapper.instance().fireAppAction).toHaveBeenCalledTimes(1);
expect(wrapper.instance().fireAppAction).toBeCalledWith(testBinding);
});
test('should match snapshot with two bindings', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={false}
appBindings={[testBinding, {...testBinding, app_id: 'app2'}]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with one extended components and one binding', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[testPlug]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={false}
appsEnabled={true}
appBindings={[testBinding]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with no extended component, in dropdown', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with one extended component, in dropdown', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[testPlug]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a single list item containing an anchor
expect(wrapper.find('li')).toHaveLength(1);
expect(wrapper.find('a')).toHaveLength(1);
});
test('should match snapshot with two extended components, in dropdown', () => {
const wrapper = mount<RawMobileChannelHeaderPlug>(
<RawMobileChannelHeaderPlug
components={[testPlug, {...testPlug, id: 'someid2'}]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
intl={intl}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a two list items containing anchors
expect(wrapper.find('li')).toHaveLength(2);
expect(wrapper.find('a')).toHaveLength(2);
const instance = wrapper.instance();
instance.fireAction = jest.fn();
wrapper.find('a').first().simulate('click');
expect(instance.fireAction).toHaveBeenCalledTimes(1);
expect(instance.fireAction).toBeCalledWith(testPlug);
});
test('should match snapshot with no binding, in dropdown', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={true}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render nothing
expect(wrapper.find('li').exists()).toBe(false);
});
test('should match snapshot with one binding, in dropdown', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={true}
appBindings={[testBinding]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a single list item containing an anchor
expect(wrapper.find('li')).toHaveLength(1);
expect(wrapper.find('a')).toHaveLength(1);
});
test('should match snapshot with two bindings, in dropdown', () => {
const wrapper = mount<RawMobileChannelHeaderPlug>(
<RawMobileChannelHeaderPlug
components={[]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={true}
appBindings={[testBinding, {...testBinding, app_id: 'app2'}]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
intl={intl}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a two list items containing anchors
expect(wrapper.find('li')).toHaveLength(2);
expect(wrapper.find('a')).toHaveLength(2);
const instance = wrapper.instance();
instance.fireAppAction = jest.fn();
wrapper.find('a').first().simulate('click');
expect(instance.fireAppAction).toHaveBeenCalledTimes(1);
expect(instance.fireAppAction).toBeCalledWith(testBinding);
});
test('should match snapshot with one extended component and one binding, in dropdown', () => {
const wrapper = mountWithIntl(
<MobileChannelHeaderPlug
components={[testPlug]}
channel={testChannel}
channelMember={testChannelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={true}
appBindings={[testBinding]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
/>,
);
expect(wrapper).toMatchSnapshot();
// Render a two list items containing anchors
expect(wrapper.find('li')).toHaveLength(2);
expect(wrapper.find('a')).toHaveLength(2);
});
test('should call plugin.action on fireAction', () => {
const channel = {id: 'channel_id'} as Channel;
const channelMember = {} as ChannelMembership;
const newTestPlug = {
id: 'someid',
pluginId: 'pluginid',
icon: <i className='fa fa-anchor'/>,
action: jest.fn(),
dropdownText: 'some dropdown text',
};
const wrapper = mount<RawMobileChannelHeaderPlug>(
<RawMobileChannelHeaderPlug
components={[newTestPlug]}
channel={channel}
channelMember={channelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={false}
appBindings={[]}
actions={{
handleBindingClick: jest.fn(),
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
intl={intl}
/>,
);
wrapper.instance().fireAction(newTestPlug);
expect(newTestPlug.action).toHaveBeenCalledTimes(1);
expect(newTestPlug.action).toBeCalledWith(channel, channelMember);
});
test('should call handleBindingClick on fireAppAction', () => {
const channel = {id: 'channel_id'} as Channel;
const channelMember = {} as ChannelMembership;
const handleBindingClick = jest.fn().mockResolvedValue({data: {type: AppCallResponseTypes.OK}});
const wrapper = mount<RawMobileChannelHeaderPlug>(
<RawMobileChannelHeaderPlug
components={[]}
channel={channel}
channelMember={channelMember}
theme={testTheme}
isDropdown={true}
appsEnabled={true}
appBindings={[testBinding]}
actions={{
handleBindingClick,
postEphemeralCallResponseForChannel: jest.fn(),
openAppsModal: jest.fn(),
}}
intl={intl}
/>,
);
const context = createCallContext(
testBinding.app_id,
testBinding.location,
channel.id,
channel.team_id,
);
wrapper.instance().fireAppAction(testBinding);
expect(handleBindingClick).toHaveBeenCalledTimes(1);
expect(handleBindingClick).toBeCalledWith(testBinding, context, expect.anything());
});
});

Просмотреть файл

@@ -1,209 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {injectIntl} from 'react-intl';
import type {IntlShape} from 'react-intl';
import type {AppBinding} from '@mattermost/types/apps';
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import {AppCallResponseTypes} from 'mattermost-redux/constants/apps';
import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
import {createCallContext} from 'utils/apps';
import type {HandleBindingClick, OpenAppsModal, PostEphemeralCallResponseForChannel} from 'types/apps';
import type {MobileChannelHeaderButtonAction} from 'types/store/plugins';
type Props = {
/*
* Components or actions to add as channel header buttons
*/
components?: MobileChannelHeaderButtonAction[];
/*
* Set to true if the plug is in the dropdown
*/
isDropdown: boolean;
channel: Channel;
channelMember?: ChannelMembership;
/*
* Logged in user's theme
*/
theme: Theme;
appBindings: AppBinding[];
appsEnabled: boolean;
intl: IntlShape;
actions: {
handleBindingClick: HandleBindingClick;
postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel;
openAppsModal: OpenAppsModal;
};
}
class MobileChannelHeaderPlug extends React.PureComponent<Props> {
createAppButton = (binding: AppBinding) => {
const onClick = () => this.fireAppAction(binding);
if (this.props.isDropdown) {
return (
<li
key={'mobileChannelHeaderItem' + binding.app_id + binding.location}
role='presentation'
className='MenuItem'
>
<a
role='menuitem'
href='#'
onClick={onClick}
>
{binding.label}
</a>
</li>
);
}
return (
<li className='flex-parent--center'>
<button
id={`${binding.app_id}_${binding.location}`}
className='navbar-toggle navbar-right__icon'
onClick={onClick}
>
<span className='icon navbar-plugin-button'>
<img
src={binding.icon}
width='16'
height='16'
/>
</span>
</button>
</li>
);
};
createButton = (plug: MobileChannelHeaderButtonAction) => {
const onClick = () => this.fireAction(plug);
if (this.props.isDropdown) {
return (
<li
key={'mobileChannelHeaderItem' + plug.id}
role='presentation'
className='MenuItem'
>
<a
role='menuitem'
href='#'
onClick={onClick}
>
{plug.dropdownText}
</a>
</li>
);
}
return (
<li className='flex-parent--center'>
<button
className='navbar-toggle navbar-right__icon'
onClick={onClick}
>
<span className='icon navbar-plugin-button'>
{plug.icon}
</span>
</button>
</li>
);
};
createList(plugs: MobileChannelHeaderButtonAction[]) {
return plugs.map(this.createButton);
}
createAppList(bindings: AppBinding[]) {
return bindings.map(this.createAppButton);
}
fireAction(plug: MobileChannelHeaderButtonAction) {
return plug.action?.(this.props.channel, this.props.channelMember);
}
fireAppAction = async (binding: AppBinding) => {
const {channel, intl} = this.props;
const context = createCallContext(
binding.app_id,
binding.location,
channel.id,
channel.team_id,
);
const res = await this.props.actions.handleBindingClick(binding, context, intl);
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
this.props.actions.postEphemeralCallResponseForChannel(errorResponse, errorMessage, channel.id);
return;
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.text) {
this.props.actions.postEphemeralCallResponseForChannel(callResp, callResp.text, channel.id);
}
break;
case AppCallResponseTypes.NAVIGATE:
break;
case AppCallResponseTypes.FORM:
if (callResp.form) {
this.props.actions.openAppsModal(callResp.form, context);
}
break;
default: {
const errorMessage = this.props.intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
this.props.actions.postEphemeralCallResponseForChannel(callResp, errorMessage, channel.id);
}
}
};
render() {
const components = this.props.components || [];
const bindings = this.props.appBindings || [];
if (components.length === 0 && bindings.length === 0) {
return null;
} else if (components.length === 1 && bindings.length === 0) {
return this.createButton(components[0]);
} else if (components.length === 0 && bindings.length === 1) {
return this.createAppButton(bindings[0]);
}
if (!this.props.isDropdown) {
return null;
}
const plugItems = this.createList(components);
const appItems = this.createAppList(bindings);
return (<>
{plugItems}
{appItems}
</>);
}
}
// Exported for tests
export {MobileChannelHeaderPlug as RawMobileChannelHeaderPlug};
export default injectIntl(MobileChannelHeaderPlug);

Просмотреть файл

@@ -35,100 +35,6 @@
flex: 1;
}
.Menu {
position: fixed;
top: 48px;
left: 0;
display: block;
overflow: hidden;
width: 100%;
height: 100%;
padding: 2.5em 0 0;
padding-bottom: 50px;
background: functions.alpha-color(variables.$black, 0.9);
transition: all 0.35s ease;
body.announcement-bar--fixed & {
top: calc(48px + #{variables.$announcement-bar-height});
height: calc(100% - 48px - #{variables.$announcement-bar-height});
}
}
.Menu__content {
position: absolute;
top: 0;
overflow: auto;
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
padding: 2rem 2rem 5rem;
background: transparent;
}
.Menu__close {
position: fixed;
top: 70px;
right: 20px;
display: block;
width: 30px;
height: 30px;
padding-top: 13px;
border: 1px solid variables.$white;
border-radius: 50%;
margin-left: -15px;
color: variables.$white;
font-family: 'Open Sans', sans-serif;
font-size: 23px;
font-weight: 200;
line-height: 0;
opacity: 1;
text-align: center;
text-shadow: none;
}
.dropdown-menu {
.dropdown__divider {
& + .dropdown__divider {
display: none;
}
}
>li {
>a,
>button,
>.styleSelectableItemDiv {
display: flex;
width: 100%;
height: auto;
justify-content: center;
padding: 0.4rem 2rem;
margin: 0 auto;
color: variables.$white;
font-weight: 600;
line-height: 32px;
text-align: center;
&:hover {
background: transparent;
}
.SubMenu__icon-right {
color: rgba(255, 255, 255, 0.75);
}
}
.navbar-right__icon {
margin: 10px;
}
hr {
border-bottom: 1px solid functions.alpha-color(variables.$white, 0.3);
margin: 8px auto;
}
}
}
.status {
top: 2px;
width: 16px;
@@ -559,24 +465,6 @@
&.dropdown-menu {
max-width: 100%;
}
&.mobile-channel-header-dropdown-enter {
transform: translateY(100%);
}
&.mobile-channel-header-dropdown-enter-active {
transform: translateY(0%);
transition: transform 0.35s ease-in;
}
&.mobile-channel-header-dropdown-enter-done {
transform: translateY(0%);
}
&.mobile-channel-header-dropdown-exit {
transform: translateY(100%);
transition: transform 0.35s ease-in;
}
}
}
@@ -2175,24 +2063,6 @@
}
}
@media screen and (max-width: 380px) and (max-height: 580px) {
#navbar_wrapper {
.navbar-default {
.dropdown-menu {
padding-top: 1em;
>li {
>a {
border: none;
font-size: 13px;
line-height: 27px;
}
}
}
}
}
}
// on iOS, allow clicks within an input's label to actually propagate through to the input itself,
// but still allow clicks to a elements to go trough
// http://stackoverflow.com/a/34810294/6325807

Просмотреть файл

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getChannelHeaderMenuPluginComponents, getPluginUserSettings} from 'selectors/plugins';
import {getChannelHeaderMenuPluginComponents, getPluginUserSettings, getChannelMobileHeaderPluginButtons} from 'selectors/plugins';
describe('selectors/plugins', () => {
describe('getPluginUserSettings', () => {
@@ -207,4 +207,34 @@ describe('selectors/plugins', () => {
]);
});
});
describe('getChannelMobileHeaderPluginButtons', () => {
it('has no settings', () => {
const state = {
plugins: {
components: {
MobileChannelHeaderButton: [],
},
},
};
const settings = getChannelMobileHeaderPluginButtons(state);
expect(settings).toEqual([]);
});
it('has settings', () => {
const headerButton = {
id: 'someid',
pluginId: 'pluginid',
dropdownText: 'some dropdown text',
};
const state = {
plugins: {
components: {
MobileChannelHeaderButton: [headerButton],
},
},
};
const settings = getChannelMobileHeaderPluginButtons(state);
expect(settings).toEqual([headerButton]);
});
});
});

Просмотреть файл

@@ -74,6 +74,14 @@ export const getChannelHeaderMenuPluginComponents = createShallowSelector(
},
);
export const getChannelMobileHeaderPluginButtons = createSelector(
'getChannelMobileHeaderPluginButtons',
(state: GlobalState) => state.plugins.components.MobileChannelHeaderButton,
(components = []) => {
return components;
},
);
export const getChannelIntroPluginButtons = createSelector(
'getChannelIntroPluginButtons',
(state: GlobalState) => state.plugins.components.ChannelIntroButton,