diff --git a/webapp/channels/src/actions/views/add_channel_dropdown.ts b/webapp/channels/src/actions/views/add_channel_dropdown.ts index 55bef92f84..251d2acf5e 100644 --- a/webapp/channels/src/actions/views/add_channel_dropdown.ts +++ b/webapp/channels/src/actions/views/add_channel_dropdown.ts @@ -9,3 +9,10 @@ export function setAddChannelDropdown(open: boolean) { open, }; } + +export function setAddChannelCtaDropdown(open: boolean) { + return { + type: ActionTypes.ADD_CHANNEL_CTA_DROPDOWN_TOGGLE, + open, + }; +} diff --git a/webapp/channels/src/components/sidebar/__snapshots__/add_channels_cta_button.test.tsx.snap b/webapp/channels/src/components/sidebar/__snapshots__/add_channels_cta_button.test.tsx.snap new file mode 100644 index 0000000000..6b9383a1d6 --- /dev/null +++ b/webapp/channels/src/components/sidebar/__snapshots__/add_channels_cta_button.test.tsx.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/new_channel_modal should match snapshot 1`] = ` + + + + + + + + + +`; diff --git a/webapp/channels/src/components/sidebar/__snapshots__/invite_members_button.test.tsx.snap b/webapp/channels/src/components/sidebar/__snapshots__/invite_members_button.test.tsx.snap index 16252a1eb0..809f40e20d 100644 --- a/webapp/channels/src/components/sidebar/__snapshots__/invite_members_button.test.tsx.snap +++ b/webapp/channels/src/components/sidebar/__snapshots__/invite_members_button.test.tsx.snap @@ -82,7 +82,7 @@ exports[`components/sidebar/invite_members_button should match snapshot 1`] = ` >
  • - <> - - + { + const original = jest.requireActual('actions/telemetry_actions.jsx'); + return { + ...original, + trackEvent: jest.fn(), + }; +}); + +const mockDispatch = jest.fn(); +let mockState: GlobalState; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux') as typeof import('react-redux'), + useSelector: (selector: (state: typeof mockState) => unknown) => selector(mockState), + useDispatch: () => mockDispatch, +})); + +describe('components/new_channel_modal', () => { + beforeEach(() => { + mockState = { + entities: { + general: { + config: {}, + }, + channels: { + currentChannelId: 'current_channel_id', + channels: {}, + roles: { + current_channel_id: [ + 'channel_user', + 'channel_admin', + ], + }, + }, + teams: { + currentTeamId: 'current_team_id', + myMembers: { + current_team_id: { + roles: 'team_user team_admin', + }, + }, + teams: { + current_team_id: { + id: 'current_team_id', + description: 'Curent team description', + name: 'current-team', + }, + }, + }, + preferences: { + myPreferences: {}, + }, + users: { + currentUserId: 'current_user_id', + profiles: { + current_user_id: {roles: 'system_user'}, + }, + }, + roles: { + roles: { + guest_user: { + permissions: [], + }, + system_user: { + permissions: [Permissions.JOIN_PUBLIC_CHANNELS, Permissions.CREATE_PRIVATE_CHANNEL, Permissions.CREATE_PUBLIC_CHANNEL], + }, + }, + }, + }, + views: { + addChannelCtaDropdown: { + isOpen: false, + }, + }, + } as unknown as GlobalState; + }); + + test('should match snapshot', () => { + expect( + shallow( + , + ), + ).toMatchSnapshot(); + }); + + test('should find the add channels button when user has permissions', () => { + const wrapper = mountWithIntl( + , + ); + expect(wrapper.find('.AddChannelsCtaDropdown').exists()).toBeTruthy(); + }); + + test('should return nothing when user does not have permissions', () => { + const guestUser = { + currentUserId: 'guest_user_id', + profiles: { + user_id: { + id: 'guest_user_id', + roles: 'team_role', + }, + }, + } as unknown as UsersState; + mockState = {...mockState, entities: {...mockState.entities, users: guestUser}}; + + const wrapper = mountWithIntl( + , + ); + expect(wrapper.find('.AddChannelsCtaDropdown').exists()).toBeFalsy(); + }); + + test('should fire dispatch to save preferences when button is clicked', () => { + const wrapper = mountWithIntl( + , + ); + const button = wrapper.find('.AddChannelsCtaDropdown button'); + expect(mockDispatch).not.toHaveBeenCalled(); + button.simulate('click'); + expect(mockDispatch).toHaveBeenCalled(); + }); + + test('should fire trackEvent to send telemetry when button is clicked', () => { + const wrapper = mountWithIntl( + , + ); + + const button = wrapper.find('.AddChannelsCtaDropdown button'); + expect(mockDispatch).not.toHaveBeenCalled(); + button.simulate('click'); + + expect(trackEvent).toHaveBeenCalledWith('ui', 'add_channels_cta_button_clicked'); + }); +}); diff --git a/webapp/channels/src/components/sidebar/add_channels_cta_button.tsx b/webapp/channels/src/components/sidebar/add_channels_cta_button.tsx new file mode 100644 index 0000000000..07ed64b9e5 --- /dev/null +++ b/webapp/channels/src/components/sidebar/add_channels_cta_button.tsx @@ -0,0 +1,155 @@ +// 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 {useSelector, useDispatch} from 'react-redux'; + +import MenuWrapper from 'components/widgets/menu/menu_wrapper'; +import Menu from 'components/widgets/menu/menu'; +import MoreChannels from 'components/more_channels'; +import NewChannelModal from 'components/new_channel_modal/new_channel_modal'; + +import {isAddChannelCtaDropdownOpen} from 'selectors/views/add_channel_dropdown'; + +import {GlobalState} from 'types/store'; + +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {haveICurrentChannelPermission} from 'mattermost-redux/selectors/entities/roles'; +import {DispatchFunc} from 'mattermost-redux/types/actions'; +import Permissions from 'mattermost-redux/constants/permissions'; +import {getBool} from 'mattermost-redux/selectors/entities/preferences'; +import {savePreferences} from 'mattermost-redux/actions/preferences'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common'; + +import {setAddChannelCtaDropdown} from 'actions/views/add_channel_dropdown'; +import {openModal} from 'actions/views/modals'; +import {trackEvent} from 'actions/telemetry_actions'; + +import {ModalIdentifiers, Preferences, Touched} from 'utils/constants'; + +const AddChannelsCtaButton = (): JSX.Element | null => { + const dispatch = useDispatch(); + const currentTeamId = useSelector(getCurrentTeamId); + const intl = useIntl(); + const touchedAddChannelsCtaButton = useSelector((state: GlobalState) => getBool(state, Preferences.TOUCHED, Touched.ADD_CHANNELS_CTA)); + + const canCreatePublicChannel = useSelector((state: GlobalState) => haveICurrentChannelPermission(state, Permissions.CREATE_PUBLIC_CHANNEL)); + const canCreatePrivateChannel = useSelector((state: GlobalState) => haveICurrentChannelPermission(state, Permissions.CREATE_PRIVATE_CHANNEL)); + const canCreateChannel = canCreatePrivateChannel || canCreatePublicChannel; + const canJoinPublicChannel = useSelector((state: GlobalState) => haveICurrentChannelPermission(state, Permissions.JOIN_PUBLIC_CHANNELS)); + const isAddChannelCtaOpen = useSelector(isAddChannelCtaDropdownOpen); + const currentUserId = useSelector(getCurrentUserId); + const openAddChannelsCtaOpen = useCallback((open: boolean) => { + dispatch(setAddChannelCtaDropdown(open)); + }, []); + + let buttonClass = 'SidebarChannelNavigator__addChannelsCtaLhsButton'; + + if (!touchedAddChannelsCtaButton) { + buttonClass += ' SidebarChannelNavigator__addChannelsCtaLhsButton--untouched'; + } + + if ((!canCreateChannel && !canJoinPublicChannel) || !currentTeamId) { + return null; + } + + const showMoreChannelsModal = () => { + dispatch(openModal({ + modalId: ModalIdentifiers.MORE_CHANNELS, + dialogType: MoreChannels, + dialogProps: {morePublicChannelsModalType: 'public'}, + })); + trackEvent('ui', 'browse_channels_button_is_clicked'); + }; + + const showNewChannelModal = () => { + dispatch(openModal({ + modalId: ModalIdentifiers.NEW_CHANNEL_MODAL, + dialogType: NewChannelModal, + })); + trackEvent('ui', 'create_new_channel_button_is_clicked'); + }; + + const renderDropdownItems = () => { + let joinPublicChannel; + if (canJoinPublicChannel) { + joinPublicChannel = ( + + ); + } + + let createChannel; + if (canCreateChannel) { + createChannel = ( + + ); + } + + return ( + <> + + {createChannel} + {joinPublicChannel} + + + ); + }; + + const trackOpen = (opened: boolean) => { + openAddChannelsCtaOpen(opened); + trackEvent('ui', 'add_channels_cta_button_clicked'); + if (!touchedAddChannelsCtaButton) { + dispatch(savePreferences( + currentUserId, + [{ + category: Preferences.TOUCHED, + user_id: currentUserId, + name: Touched.ADD_CHANNELS_CTA, + value: 'true', + }], + )); + } + }; + + return ( + + + + {renderDropdownItems()} + + + ); +}; + +export default AddChannelsCtaButton; diff --git a/webapp/channels/src/components/sidebar/invite_members_button.tsx b/webapp/channels/src/components/sidebar/invite_members_button.tsx index 0ff3799858..0f3f602d5f 100644 --- a/webapp/channels/src/components/sidebar/invite_members_button.tsx +++ b/webapp/channels/src/components/sidebar/invite_members_button.tsx @@ -32,7 +32,7 @@ type Props = { isAdmin: boolean; } -const InviteMembersButton: React.FC = (props: Props): JSX.Element | null => { +const InviteMembersButton = (props: Props): JSX.Element | null => { const dispatch = useDispatch(); const intl = useIntl(); @@ -50,10 +50,10 @@ const InviteMembersButton: React.FC = (props: Props): JSX.Element | null props.onClick(); }; - let buttonClass = 'SidebarChannelNavigator_inviteMembersLhsButton'; + let buttonClass = 'SidebarChannelNavigator__inviteMembersLhsButton'; if (!props.touchedInviteMembersButton && Number(totalUserCount) <= Constants.USER_LIMIT) { - buttonClass += ' SidebarChannelNavigator_inviteMembersLhsButton--untouched'; + buttonClass += ' SidebarChannelNavigator__inviteMembersLhsButton--untouched'; } if (!currentTeamId || !totalUserCount) { diff --git a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category.tsx b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category.tsx index fe33dd7da6..c770a146f6 100644 --- a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category.tsx @@ -24,6 +24,8 @@ import KeyboardShortcutSequence, { KEYBOARD_SHORTCUTS, } from 'components/keyboard_shortcuts/keyboard_shortcuts_sequence'; +import AddChannelsCtaButton from '../add_channels_cta_button'; + import SidebarCategorySortingMenu from './sidebar_category_sorting_menu'; import SidebarCategoryMenu from './sidebar_category_menu'; @@ -342,6 +344,13 @@ export default class SidebarCategory extends React.PureComponent { ); } + let addChannelsCtaButton = null; + if (category.type === 'channels' && !category.collapsed) { + addChannelsCtaButton = ( + + ); + } + return (
    { }} {inviteMembersButton} + {addChannelsCtaButton}
    ); }} diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 2b69918b75..b7458e403a 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4854,6 +4854,7 @@ "shortcuts.team_nav.prev.mac": "Previous team:\t⌘|⌥|Up", "shortcuts.team_nav.switcher": "Navigate to a specific team:\tCtrl|Alt|[1-9]", "shortcuts.team_nav.switcher.mac": "Navigate to a specific team:\t⌘|⌥|[1-9]", + "sidebar_left.add_channel_cta_dropdown.dropdownAriaLabel": "Add Channel Dropdown", "sidebar_left.add_channel_dropdown.browseChannels": "Browse Channels", "sidebar_left.add_channel_dropdown.browseOrCreateChannels": "Browse or create channels", "sidebar_left.add_channel_dropdown.createCategory": "Create New Category", @@ -4863,6 +4864,7 @@ "sidebar_left.add_channel_dropdown.invitePeopleExtraText": "Add people to the team", "sidebar_left.add_channel_dropdown.work_template": "Create from a template", "sidebar_left.add_channel_dropdown.work_template_extra": "Set up a channel with linked boards, and playbooks", + "sidebar_left.addChannelsCta": "Add channels", "sidebar_left.channel_filter.filterByUnread": "Filter by unread", "sidebar_left.channel_filter.filterUnreadAria": "unreads filter", "sidebar_left.channel_filter.showAllChannels": "Show all channels", @@ -4901,6 +4903,7 @@ "sidebar_left.sidebar_channel_menu.unfavoriteChannel": "Unfavorite", "sidebar_left.sidebar_channel_menu.unmuteChannel": "Unmute Channel", "sidebar_left.sidebar_channel_menu.unmuteConversation": "Unmute Conversation", + "sidebar_left.sidebar_channel_navigator.addChannelsCta": "Add channels", "sidebar_left.sidebar_channel_navigator.inviteUsers": "Invite Users", "sidebar_left.sidebar_channel.selectedCount": "{count} selected", "sidebar_right_menu.console": "System Console", diff --git a/webapp/channels/src/reducers/views/add_channel_cta_dropdown.ts b/webapp/channels/src/reducers/views/add_channel_cta_dropdown.ts new file mode 100644 index 0000000000..c468169bac --- /dev/null +++ b/webapp/channels/src/reducers/views/add_channel_cta_dropdown.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {combineReducers} from 'redux'; + +import {GenericAction} from 'mattermost-redux/types/actions'; + +import {ActionTypes} from 'utils/constants'; + +export function isOpen(state = false, action: GenericAction) { + switch (action.type) { + case ActionTypes.ADD_CHANNEL_CTA_DROPDOWN_TOGGLE: + return action.open; + default: + return state; + } +} + +export default combineReducers({ + isOpen, +}); diff --git a/webapp/channels/src/reducers/views/index.ts b/webapp/channels/src/reducers/views/index.ts index 2f574e32ba..6d305e40d8 100644 --- a/webapp/channels/src/reducers/views/index.ts +++ b/webapp/channels/src/reducers/views/index.ts @@ -25,6 +25,7 @@ import productMenu from './product_menu'; import textbox from './textbox'; import statusDropdown from './status_dropdown'; import addChannelDropdown from './add_channel_dropdown'; +import addChannelCtaDropdown from './add_channel_cta_dropdown'; import threads from './threads'; import onboardingTasks from './onboarding_tasks'; @@ -50,6 +51,7 @@ export default combineReducers({ channelSidebar, statusDropdown, addChannelDropdown, + addChannelCtaDropdown, onboardingTasks, threads, productMenu, diff --git a/webapp/channels/src/sass/layout/_sidebar-left.scss b/webapp/channels/src/sass/layout/_sidebar-left.scss index 8baf4ac923..c670182893 100644 --- a/webapp/channels/src/sass/layout/_sidebar-left.scss +++ b/webapp/channels/src/sass/layout/_sidebar-left.scss @@ -178,6 +178,46 @@ $sidebarOpacityAnimationDuration: 0.15s; } } + &__inviteMembersLhsButton, + &__addChannelsCtaLhsButton { + display: flex; + padding: 6px 0; + margin-left: 15px; + color: rgba(var(--sidebar-text-rgb), 0.72); + line-height: 20px; + list-style: none; + + i { + font-size: 20px; + } + + span { + align-self: flex-end; + margin-top: -2px; + margin-left: 5px; + } + + &--untouched { + color: var(--sidebar-unread-text); + font-weight: $font-weight--semibold; + + i::before { + font-weight: $font-weight--semibold; + } + } + } + + &__addChannelsCtaLhsButton { + border: none; + margin-left: 0; + background: none; + + li { + display: flex; + margin-left: 15px; + } + } + .AddChannelDropdown_dropdownButton, .SidebarChannelNavigator_inviteUsers, .SidebarChannelNavigator_jumpToButton, @@ -700,35 +740,12 @@ $sidebarOpacityAnimationDuration: 0.15s; } } - .SidebarChannelNavigator_inviteMembersLhsButton { - display: flex; - padding: 6px 0; - margin-left: 15px; - color: rgba(var(--sidebar-text-rgb), 0.72); - line-height: 20px; - list-style: none; - - i { - font-size: 20px; - } - - span { - align-self: flex-end; - margin-top: -2px; - margin-left: 5px; - } - - &--untouched { - color: var(--sidebar-unread-text); - font-weight: $font-weight--semibold; - - i::before { - font-weight: $font-weight--semibold; - } - } + .AddChannelsCtaDropdown .dropdown-menu { + margin-left: 20px; } - #introTextInvite { + #introTextInvite, + #addChannelsCta { display: flex; width: 100%; @@ -737,6 +754,14 @@ $sidebarOpacityAnimationDuration: 0.15s; } } + #AddChannelCtaDropdown { + position: fixed; + + ul { + min-width: 232px !important; + } + } + .SidebarChannelNavigator_inviteUsersSticky { position: absolute; z-index: 2; diff --git a/webapp/channels/src/selectors/views/add_channel_dropdown.ts b/webapp/channels/src/selectors/views/add_channel_dropdown.ts index a106cb8602..84f41d9e9c 100644 --- a/webapp/channels/src/selectors/views/add_channel_dropdown.ts +++ b/webapp/channels/src/selectors/views/add_channel_dropdown.ts @@ -6,3 +6,7 @@ import {GlobalState} from 'types/store'; export function isAddChannelDropdownOpen(state: GlobalState) { return state.views.addChannelDropdown.isOpen; } + +export function isAddChannelCtaDropdownOpen(state: GlobalState) { + return state.views.addChannelCtaDropdown.isOpen; +} diff --git a/webapp/channels/src/types/store/views.ts b/webapp/channels/src/types/store/views.ts index 5e67a97ddd..19f34b7744 100644 --- a/webapp/channels/src/types/store/views.ts +++ b/webapp/channels/src/types/store/views.ts @@ -180,6 +180,10 @@ export type ViewsState = { isOpen: boolean; }; + addChannelCtaDropdown: { + isOpen: boolean; + }; + onboardingTasks: { isShowOnboardingTaskCompletion: boolean; isShowOnboardingCompleteProfileTour: boolean; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 5a5825ec5a..ad18170d15 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -163,6 +163,7 @@ export const Preferences = { // For one off things that have a special, attention-grabbing UI until you interact with them export const Touched = { INVITE_MEMBERS: 'invite_members', + ADD_CHANNELS_CTA: 'add_channels_cta', }; // Category for actions/interactions that will happen just once @@ -264,6 +265,7 @@ export const ActionTypes = keyMirror({ STATUS_DROPDOWN_TOGGLE: null, ADD_CHANNEL_DROPDOWN_TOGGLE: null, + ADD_CHANNEL_CTA_DROPDOWN_TOGGLE: null, SHOW_ONBOARDING_TASK_COMPLETION: null, SHOW_ONBOARDING_COMPLETE_PROFILE_TOUR: null,