Merge branch 'master' into fix-boards-webapp-unit-tests

Этот коммит содержится в:
Caleb Roseland
2023-03-29 09:37:24 -05:00
родитель 4331ad24d3 c78d6d47ac
Коммит 6da94c769c
1364 изменённых файлов: 4408 добавлений и 4279 удалений

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

@@ -46,7 +46,6 @@
"glob-parent": "6.0.2",
"lodash": "^4.17.21",
"marked": "4.0.17",
"mattermost-redux": "5.33.1",
"mini-create-react-context": "^0.4.1",
"moment": "^2.29.1",
"nanoevents": "^5.1.13",

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

@@ -96,3 +96,9 @@ exports[`components/boardsUnfurl/BoardsUnfurl renders when limited 1`] = `
</a>
</div>
`;
exports[`components/boardsUnfurl/BoardsUnfurl test invalid card, invalid block 1`] = `<div />`;
exports[`components/boardsUnfurl/BoardsUnfurl test invalid card, valid block 1`] = `<div />`;
exports[`components/boardsUnfurl/BoardsUnfurl test no card 1`] = `<div />`;

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

@@ -16,6 +16,8 @@ import {createBoard} from 'src/blocks/board'
import octoClient from 'src/octoClient'
import {wrapIntl} from 'src/testUtils'
import {createBoardView} from 'src/blocks/boardView'
import BoardsUnfurl from './boardsUnfurl'
jest.mock('src/octoClient')
@@ -114,5 +116,118 @@ describe('components/boardsUnfurl/BoardsUnfurl', () => {
expect(container).toMatchSnapshot()
})
it('test no card', async () => {
const mockStore = configureStore([])
const store = mockStore({
language: {
value: 'en',
},
teams: {
allTeams: [team],
current: team,
},
})
const board = {...createBoard(), title: 'test board'}
// mockedOctoClient.getBoard.mockResolvedValueOnce(board)
const component = (
<ReduxProvider store={store}>
{wrapIntl(
<BoardsUnfurl
embed={{data: JSON.stringify({workspaceID: 'foo', cardID: '', boardID: board.id, readToken: 'abc', originalPath: '/test'})}}
/>,
)}
</ReduxProvider>
)
let container: Element | DocumentFragment | null = null
await act(async () => {
const result = render(component)
container = result.container
})
expect(container).toMatchSnapshot()
})
it('test invalid card, valid block', async () => {
const mockStore = configureStore([])
const store = mockStore({
language: {
value: 'en',
},
teams: {
allTeams: [team],
current: team,
},
})
const cards = [{...createBoardView(), title: 'test view', updateAt: 12345}]
const board = {...createBoard(), title: 'test board'}
mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards)
mockedOctoClient.getBoard.mockResolvedValueOnce(board)
const component = (
<ReduxProvider store={store}>
{wrapIntl(
<BoardsUnfurl
embed={{data: JSON.stringify({workspaceID: 'foo', cardID: cards[0].id, boardID: board.id, readToken: 'abc', originalPath: '/test'})}}
/>,
)}
</ReduxProvider>
)
let container: Element | DocumentFragment | null = null
await act(async () => {
const result = render(component)
container = result.container
})
expect(mockedOctoClient.getBoard).toBeCalledWith(board.id)
expect(mockedOctoClient.getBlocksWithBlockID).toBeCalledWith(cards[0].id, board.id, 'abc')
expect(container).toMatchSnapshot()
})
it('test invalid card, invalid block', async () => {
const mockStore = configureStore([])
const store = mockStore({
language: {
value: 'en',
},
teams: {
allTeams: [team],
current: team,
},
})
const board = {...createBoard(), title: 'test board'}
mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce([])
mockedOctoClient.getBoard.mockResolvedValueOnce(board)
const component = (
<ReduxProvider store={store}>
{wrapIntl(
<BoardsUnfurl
embed={{data: JSON.stringify({workspaceID: 'foo', cardID: 'invalidCard', boardID: board.id, readToken: 'abc', originalPath: '/test'})}}
/>,
)}
</ReduxProvider>
)
let container: Element | DocumentFragment | null = null
await act(async () => {
const result = render(component)
container = result.container
})
expect(mockedOctoClient.getBoard).toBeCalledWith(board.id)
expect(mockedOctoClient.getBlocksWithBlockID).toBeCalledWith('invalidCard', board.id, 'abc')
expect(container).toMatchSnapshot()
})
})

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

@@ -84,7 +84,7 @@ export const BoardsUnfurl = (props: Props): JSX.Element => {
],
)
const [firstCard] = cards as Card[]
if (!firstCard || !fetchedBoard) {
if (!firstCard || !fetchedBoard || firstCard.type !== 'card') {
setLoading(false)
return null
}
@@ -116,7 +116,7 @@ export const BoardsUnfurl = (props: Props): JSX.Element => {
useWebsockets(currentTeamId, (wsClient: WSClient) => {
const onChangeHandler = (_: WSClient, blocks: Block[]): void => {
const cardBlock: Block|undefined = blocks.find((b) => b.id === cardID)
if (cardBlock && !cardBlock.deleteAt) {
if (cardBlock && !cardBlock.deleteAt && cardBlock.type === 'card') {
setCard(cardBlock as Card)
}

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

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react'
import {Post} from 'mattermost-redux/types/posts'
import {Post} from '@mattermost/types/posts'
const PostTypeCloudUpgradeNudge = (props: {post: Post}): JSX.Element => {
const ctaHandler = (e: React.MouseEvent) => {

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

@@ -6,16 +6,14 @@ import {Store, Action} from 'redux'
import {Provider as ReduxProvider} from 'react-redux'
import {createBrowserHistory, History} from 'history'
import {rudderAnalytics, RudderTelemetryHandler} from 'mattermost-redux/client/rudder'
import {GlobalState} from 'mattermost-redux/types/store'
import {selectTeam} from 'mattermost-redux/actions/teams'
import {GlobalState} from '@mattermost/types/store'
import {SuiteWindow} from 'src/types/index'
import {PluginRegistry} from 'src/types/mattermost-webapp'
import {rudderAnalytics, RudderTelemetryHandler} from 'src/rudder'
import appBarIcon from 'static/app-bar-icon.png'
import {Constants} from 'src/constants'
@@ -85,7 +83,7 @@ function getSubpath(siteURL: string): string {
return url.pathname.replace(/\/+$/, '')
}
const TELEMETRY_RUDDER_KEY = 'placeholder_rudder_key'
const TELEMETRY_RUDDER_KEY = 'placeholder_boards_rudder_key'
const TELEMETRY_RUDDER_DATAPLANE_URL = 'placeholder_rudder_dataplane_url'
const TELEMETRY_OPTIONS = {
context: {
@@ -294,9 +292,13 @@ export default class Plugin {
const currentUserId = mmStore.getState().entities.users.currentUserId
if (currentTeamID !== fbPrevTeamID) {
fbPrevTeamID = currentTeamID
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mmStore.dispatch(selectTeam(currentTeamID))
mmStore.dispatch({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
type: 'SELECT_TEAM',
data: currentTeamID,
})
localStorage.setItem(`user_prev_team:${currentUserId}`, currentTeamID)
}
})

65
webapp/boards/src/rudder.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// This file is duplicated from mattermost-redux in the web app with some slight modifications to make it standalone
// As per rudder-sdk-js documentation, import this only once and use like a singleton.
// See https://github.com/rudderlabs/rudder-sdk-js#step-1-install-rudderstack-using-the-code-snippet
import * as rudderAnalytics from 'rudder-sdk-js'
export {rudderAnalytics}
import {TelemetryHandler} from '@mattermost/client'
import {Utils} from 'src/utils'
export class RudderTelemetryHandler implements TelemetryHandler {
trackEvent(userId: string, userRoles: string, category: string, event: string, props?: any) {
const properties = Object.assign({
category,
type: event,
user_actual_role: getActualRoles(userRoles),
user_actual_id: userId,
}, props)
const options = {
context: {
ip: '0.0.0.0',
},
page: {
path: '',
referrer: '',
search: '',
title: '',
url: '',
},
anonymousId: '00000000000000000000000000',
}
rudderAnalytics.track('event', properties, options)
}
pageVisited(userId: string, userRoles: string, category: string, name: string) {
rudderAnalytics.page(
category,
name,
{
path: '',
referrer: '',
search: '',
title: '',
url: '',
user_actual_role: getActualRoles(userRoles),
user_actual_id: userId,
},
{
context: {
ip: '0.0.0.0',
},
anonymousId: '00000000000000000000000000',
},
)
}
}
function getActualRoles(userRoles: string) {
return userRoles && Utils.isSystemAdmin(userRoles) ? 'system_admin, system_user' : 'system_user'
}

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

@@ -3,7 +3,7 @@
import type React from 'react'
import type {Channel, ChannelMembership} from 'mattermost-redux/types/channels'
import type {Channel, ChannelMembership} from '@mattermost/types/channels'
type ReactResolvable = React.ReactNode | React.ElementType

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

@@ -53,8 +53,6 @@ const config = {
resolve: {
alias: {
src: path.resolve(__dirname, './src/'),
// 'mattermost-redux': path.resolve(__dirname, '../channels/src/packages/mattermost-redux/src/'),
// reselect: path.resolve(__dirname, '../channels/src/packages/reselect/src/index'),
'@mattermost/client': path.resolve(__dirname, '../platform/client/src/'),
'@mattermost/components': path.resolve(__dirname, '../platform/components/src/'),
},

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

@@ -1,4 +0,0 @@
# Web Platform should be assigned to review all PRs that involve changing dependencies in the app, either intentional or accidental.
package.json @mattermost/web-platform
*/package.json @mattermost/web-platform
package-lock.json @mattermost/web-platform

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

@@ -167,7 +167,7 @@ describe('Actions.Channel', () => {
}],
}];
await testStore.dispatch(searchMoreChannels('', false, true));
await testStore.dispatch(searchMoreChannels('', false));
expect(testStore.getActions()).toEqual(expectedActions);
});

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

@@ -109,7 +109,7 @@ export function loadChannelsForCurrentUser(): ActionFunc {
};
}
export function searchMoreChannels(term: string, showArchivedChannels: boolean, hideJoinedChannels: boolean): ActionFunc<Channel[], ServerError> {
export function searchMoreChannels(term: string, showArchivedChannels: boolean): ActionFunc<Channel[], ServerError> {
return async (dispatch, getState) => {
const state = getState();
const teamId = getCurrentTeamId(state);
@@ -121,7 +121,9 @@ export function searchMoreChannels(term: string, showArchivedChannels: boolean,
const {data, error} = await dispatch(ChannelActions.searchChannels(teamId, term, showArchivedChannels));
if (data) {
const myMembers = getMyChannelMemberships(state);
const channels = hideJoinedChannels ? (data as Channel[]).filter((channel) => !myMembers[channel.id]) : data;
// When searching public channels, only get channels user is not a member of
const channels = showArchivedChannels ? data : (data as Channel[]).filter((c) => !myMembers[c.id]);
return {data: channels};
}

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

@@ -6,6 +6,7 @@ import {Client4} from 'mattermost-redux/client';
import * as Channels from 'mattermost-redux/selectors/entities/channels';
import * as Teams from 'mattermost-redux/selectors/entities/teams';
import {Permissions} from 'mattermost-redux/constants';
import {AppCallResponseTypes} from 'mattermost-redux/constants/apps';
import * as GlobalActions from 'actions/global_actions';
@@ -55,7 +56,7 @@ const initialState = {
roles: {
custom_role: {
permissions: [
'sysconsole_read_plugins',
Permissions.SYSCONSOLE_WRITE_PLUGINS,
],
},
},

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

@@ -130,7 +130,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFunc {
return {data: true};
case '/marketplace':
// check if user has permissions to access the read plugins
if (!haveICurrentTeamPermission(state, Permissions.SYSCONSOLE_READ_PLUGINS)) {
if (!haveICurrentTeamPermission(state, Permissions.SYSCONSOLE_WRITE_PLUGINS)) {
return {error: {message: localizeMessage('marketplace_command.no_permission', 'You do not have the appropriate permissions to access the marketplace.')}};
}
@@ -139,7 +139,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFunc {
return {error: {message: localizeMessage('marketplace_command.disabled', 'The marketplace is disabled. Please contact your System Administrator for details.')}};
}
dispatch(openModal({modalId: ModalIdentifiers.PLUGIN_MARKETPLACE, dialogType: MarketplaceModal}));
dispatch(openModal({modalId: ModalIdentifiers.PLUGIN_MARKETPLACE, dialogType: MarketplaceModal, dialogProps: {openedFrom: 'command'}}));
return {data: true};
case '/templates': {
const workTemplateEnabled = areWorkTemplatesEnabled(state);

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

@@ -65,7 +65,7 @@ export function emitChannelClickEvent(channel: Channel) {
const currentChannelId = getCurrentChannelId(state);
const previousRhsState = getPreviousRhsState(state);
dispatch(getChannelStats(chan.id));
dispatch(getChannelStats(chan.id, true));
const penultimate = LocalStorageStore.getPreviousChannelName(userId, teamId);
const penultimateType = LocalStorageStore.getPreviousViewedType(userId, teamId);

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

@@ -9,3 +9,10 @@ export function setAddChannelDropdown(open: boolean) {
open,
};
}
export function setAddChannelCtaDropdown(open: boolean) {
return {
type: ActionTypes.ADD_CHANNEL_CTA_DROPDOWN_TOGGLE,
open,
};
}

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

@@ -0,0 +1,46 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/SearchableChannelList should match init snapshot 1`] = `
<div
className="filtered-user-list"
>
<div
className="filter-row filter-row--full"
>
<div
className="col-sm-12"
>
<QuickInput
className="form-control filter-textbox"
id="searchChannelsTextbox"
inputComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"render": [Function],
}
}
onInput={[Function]}
placeholder={
Object {
"defaultMessage": "Search channels",
"id": "filtered_channels_list.search",
}
}
/>
</div>
</div>
<div
className="more-modal__list"
role="application"
>
<div
id="moreChannelsList"
>
<LoadingScreen />
</div>
</div>
<div
className="filter-controls"
/>
</div>
`;

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

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/actions_menu/ActionsMenu has actions - end user - should not show actions and app marketplace 1`] = `
exports[`components/actions_menu/ActionsMenu has actions - marketplace disabled or user not having SYSCONSOLE_WRITE_PLUGINS - should not show actions and app marketplace 1`] = `
<MenuWrapper
animationComponent={[Function]}
className=""
@@ -68,7 +68,7 @@ exports[`components/actions_menu/ActionsMenu has actions - end user - should not
</MenuWrapper>
`;
exports[`components/actions_menu/ActionsMenu has actions - sysadmin - should show actions and app marketplace 1`] = `
exports[`components/actions_menu/ActionsMenu has actions - marketplace enabled and user has SYSCONSOLE_WRITE_PLUGINS - should show actions and app marketplace 1`] = `
<MenuWrapper
animationComponent={[Function]}
className=""

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

@@ -45,6 +45,7 @@ describe('components/actions_menu/ActionsMenu', () => {
handleDismissTip: jest.fn(),
showPulsatingDot: false,
location: 'center',
canOpenMarketplace: false,
actions: {
openModal: jest.fn(),
openAppsModal: jest.fn(),
@@ -62,27 +63,29 @@ describe('components/actions_menu/ActionsMenu', () => {
wrapper.setProps({
pluginMenuItems: dropdownComponents,
canOpenMarketplace: true,
});
expect(wrapper.find('#divider_post_post_id_1_marketplace').exists()).toBe(true);
});
test('has actions - sysadmin - should show actions and app marketplace', () => {
test('has actions - marketplace enabled and user has SYSCONSOLE_WRITE_PLUGINS - should show actions and app marketplace', () => {
const wrapper = shallowWithIntl(
<ActionsMenu {...baseProps}/>,
);
wrapper.setProps({
pluginMenuItems: dropdownComponents,
canOpenMarketplace: true,
});
expect(wrapper).toMatchSnapshot();
});
test('has actions - end user - should not show actions and app marketplace', () => {
test('has actions - marketplace disabled or user not having SYSCONSOLE_WRITE_PLUGINS - should not show actions and app marketplace', () => {
const wrapper = shallowWithIntl(
<ActionsMenu {...baseProps}/>,
);
wrapper.setProps({
pluginMenuItems: dropdownComponents,
isSysAdmin: false,
canOpenMarketplace: false,
});
expect(wrapper).toMatchSnapshot();
});
@@ -91,6 +94,11 @@ describe('components/actions_menu/ActionsMenu', () => {
const wrapper = shallowWithIntl(
<ActionsMenu {...baseProps}/>,
);
wrapper.setProps({
canOpenMarketplace: true,
});
expect(wrapper).toMatchSnapshot();
});
@@ -116,6 +124,7 @@ describe('components/actions_menu/ActionsMenu', () => {
components: {
[PLUGGABLE_COMPONENT]: dropdownComponents,
},
canOpenMarketplace: true,
});
expect(wrapper.find('#divider_post_post_id_1_marketplace').exists()).toBe(true);
});

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

@@ -20,6 +20,7 @@ import Permissions from 'mattermost-redux/constants/permissions';
import {ActionsTutorialTip} from 'components/actions_menu/actions_menu_tutorial_tip';
import {ModalData} from 'types/actions';
import MarketplaceModal from 'components/plugin_marketplace';
import {OpenedFromType} from 'components/plugin_marketplace/marketplace_modal';
import OverlayTrigger from 'components/overlay_trigger';
import * as PostUtils from 'utils/post_utils';
import * as Utils from 'utils/utils';
@@ -49,6 +50,7 @@ export type Props = {
handleDismissTip: () => void;
showPulsatingDot?: boolean;
showTutorialTip: boolean;
canOpenMarketplace: boolean;
/**
* Components for overriding provided by plugins
@@ -145,9 +147,11 @@ export class ActionMenuClass extends React.PureComponent<Props, State> {
}
handleOpenMarketplace = (): void => {
const openedFrom: OpenedFromType = 'actions_menu';
const openMarketplaceData = {
modalId: ModalIdentifiers.PLUGIN_MARKETPLACE,
dialogType: MarketplaceModal,
dialogProps: {openedFrom},
};
this.props.actions.openModal(openMarketplaceData);
};
@@ -341,7 +345,7 @@ export class ActionMenuClass extends React.PureComponent<Props, State> {
const {formatMessage} = this.props.intl;
let marketPlace = null;
if (this.props.isSysAdmin) {
if (this.props.canOpenMarketplace) {
marketPlace = (
<React.Fragment key={'marketplace'}>
{this.renderDivider('marketplace')}
@@ -363,11 +367,11 @@ export class ActionMenuClass extends React.PureComponent<Props, State> {
const hasPluginItems = Boolean(pluginItems?.length);
const hasPluginMenuItems = hasPluginItems || hasApps || hasPluggables;
if (!this.props.isSysAdmin && !hasPluginMenuItems) {
if (!this.props.canOpenMarketplace && !hasPluginMenuItems) {
return null;
}
if (hasPluginItems || hasApps || hasPluggables) {
if (hasPluginMenuItems) {
const pluggable = (
<Pluggable
postId={this.props.post.id}

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

@@ -44,6 +44,7 @@ describe('components/actions_menu/ActionsMenu returning empty ("")', () => {
showTutorialTip: false,
appsEnabled: false,
isSysAdmin: true,
canOpenMarketplace: false,
};
const wrapper = shallow(

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

@@ -44,6 +44,7 @@ describe('components/actions_menu/ActionsMenu on mobile view', () => {
showTutorialTip: false,
appsEnabled: false,
isSysAdmin: true,
canOpenMarketplace: false,
};
const wrapper = shallow(

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

@@ -26,6 +26,10 @@ import {GlobalState} from 'types/store';
import {openModal} from 'actions/views/modals';
import {makeFetchBindings, postEphemeralCallResponseForPost, handleBindingClick, openAppsModal} from 'actions/apps';
import {Permissions} from 'mattermost-redux/constants';
import {isMarketplaceEnabled} from 'mattermost-redux/selectors/entities/general';
import {haveICurrentTeamPermission} from 'mattermost-redux/selectors/entities/roles';
import ActionsMenu from './actions_menu';
import {makeGetPostOptionBinding} from './selectors';
@@ -65,6 +69,10 @@ function mapStateToProps(state: GlobalState, ownProps: Props) {
pluginMenuItems: state.plugins.components.PostDropdownMenu,
teamId: getCurrentTeamId(state),
isMobileView: getIsMobileView(state),
canOpenMarketplace: (
isMarketplaceEnabled(state) &&
haveICurrentTeamPermission(state, Permissions.SYSCONSOLE_WRITE_PLUGINS)
),
};
}

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

@@ -5,16 +5,12 @@ import React from 'react';
import {FormattedMessage} from 'react-intl';
import {trackEvent} from 'actions/telemetry_actions';
import {useOpenCloudZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import ExternalLink from 'components/external_link';
type Props = {
cancelAccountLink: any;
}
const CancelSubscription = (props: Props) => {
const {
cancelAccountLink,
} = props;
const CancelSubscription = () => {
const description = `I am requesting that workspace "${window.location.host}" be deleted`;
const [, contactSupportURL] = useOpenCloudZendeskSupportForm('Request workspace be deleted', description);
return (
<div className='cancelSubscriptionSection'>
@@ -33,7 +29,7 @@ const CancelSubscription = (props: Props) => {
</div>
<ExternalLink
location='cancel_subscription'
href={cancelAccountLink}
href={contactSupportURL}
className='cancelSubscriptionSection__contactUs'
onClick={() => trackEvent('cloud_admin', 'click_contact_us')}
>

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

@@ -24,7 +24,6 @@ import AlertBanner from 'components/alert_banner';
import UpgradeLink from 'components/widgets/links/upgrade_link';
import './cloud_trial_banner.scss';
import {SalesInquiryIssue} from 'selectors/cloud';
export interface Props {
trialEndDate: number;
@@ -34,7 +33,7 @@ const CloudTrialBanner = ({trialEndDate}: Props): JSX.Element | null => {
const endDate = new Date(trialEndDate);
const DISMISSED_DAYS = 10;
const {formatMessage} = useIntl();
const openSalesLink = useOpenSalesLink(SalesInquiryIssue.UpgradeEnterprise);
const [openSalesLink] = useOpenSalesLink();
const dispatch = useDispatch();
const user = useSelector(getCurrentUser);
const storedDismissedEndDate = useSelector((state: GlobalState) => getPreference(state, Preferences.CLOUD_TRIAL_BANNER, CloudBanners.UPGRADE_FROM_TRIAL));

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

@@ -10,21 +10,19 @@ import {CloudLinks, CloudProducts} from 'utils/constants';
import PrivateCloudSvg from 'components/common/svg_images_components/private_cloud_svg';
import CloudTrialSvg from 'components/common/svg_images_components/cloud_trial_svg';
import {TelemetryProps} from 'components/common/hooks/useOpenPricingModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import ExternalLink from 'components/external_link';
type Props = {
contactSalesLink: any;
isFreeTrial: boolean;
trialQuestionsLink: any;
subscriptionPlan: string | undefined;
onUpgradeMattermostCloud: (telemetryProps?: TelemetryProps | undefined) => void;
}
const ContactSalesCard = (props: Props) => {
const [openSalesLink, contactSalesLink] = useOpenSalesLink();
const {
contactSalesLink,
isFreeTrial,
trialQuestionsLink,
subscriptionPlan,
onUpgradeMattermostCloud,
} = props;
@@ -145,7 +143,7 @@ const ContactSalesCard = (props: Props) => {
{(isFreeTrial || subscriptionPlan === CloudProducts.ENTERPRISE || isCloudLegacyPlan) &&
<ExternalLink
location='contact_sales_card'
href={isFreeTrial ? trialQuestionsLink : contactSalesLink}
href={contactSalesLink}
className='PrivateCloudCard__actionButton'
onClick={() => trackEvent('cloud_admin', 'click_contact_sales')}
>
@@ -163,7 +161,7 @@ const ContactSalesCard = (props: Props) => {
if (subscriptionPlan === CloudProducts.STARTER) {
onUpgradeMattermostCloud({trackingLocation: 'admin_console_subscription_card_upgrade_now_button'});
} else {
window.open(contactSalesLink, '_blank');
openSalesLink();
}
}}
className='PrivateCloudCard__actionButton'

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

@@ -13,7 +13,6 @@ import FormattedAdminHeader from 'components/widgets/admin_console/formatted_adm
import CloudTrialBanner from 'components/admin_console/billing/billing_subscriptions/cloud_trial_banner';
import CloudFetchError from 'components/cloud_fetch_error';
import {getCloudContactUsLink, InquiryType, SalesInquiryIssue} from 'selectors/cloud';
import {
getSubscriptionProduct,
getCloudSubscription as selectCloudSubscription,
@@ -63,9 +62,6 @@ const BillingSubscriptions = () => {
const isCardExpired = isCustomerCardExpired(useSelector(selectCloudCustomer));
const contactSalesLink = useSelector(getCloudContactUsLink)(InquiryType.Sales);
const cancelAccountLink = useSelector(getCloudContactUsLink)(InquiryType.Sales, SalesInquiryIssue.CancelAccount);
const trialQuestionsLink = useSelector(getCloudContactUsLink)(InquiryType.Sales, SalesInquiryIssue.TrialQuestions);
const trialEndDate = subscription?.trial_end_at || 0;
const [showCreditCardBanner, setShowCreditCardBanner] = useState(true);
@@ -159,19 +155,12 @@ const BillingSubscriptions = () => {
<Limits/>
) : (
<ContactSalesCard
contactSalesLink={contactSalesLink}
isFreeTrial={isFreeTrial}
trialQuestionsLink={trialQuestionsLink}
subscriptionPlan={product?.sku}
onUpgradeMattermostCloud={openPricingModal}
/>
)}
{isAnnualProfessionalOrEnterprise && !isFreeTrial ?
<CancelSubscription
cancelAccountLink={cancelAccountLink}
/> :
<DeleteWorkspaceCTA/>
}
{isAnnualProfessionalOrEnterprise && !isFreeTrial ? <CancelSubscription/> : <DeleteWorkspaceCTA/>}
</>}
</div>
</div>

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

@@ -177,7 +177,7 @@ describe('limits_reached_banner', () => {
const store = mockStore(state);
const spies = makeSpies();
const mockOpenSalesLink = jest.fn();
spies.useOpenSalesLink.mockReturnValue(mockOpenSalesLink);
spies.useOpenSalesLink.mockReturnValue([mockOpenSalesLink, '']);
spies.useGetUsageDeltas.mockReturnValue(someLimitReached);
renderWithIntl(<Provider store={store}><LimitReachedBanner product={free}/></Provider>);
screen.getByText(titleFree);

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

@@ -5,8 +5,6 @@ import React from 'react';
import {useIntl, FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {SalesInquiryIssue} from 'selectors/cloud';
import {CloudProducts} from 'utils/constants';
import {anyUsageDeltaExceededLimit} from 'utils/limits';
@@ -33,7 +31,7 @@ const LimitReachedBanner = (props: Props) => {
const hasDismissedBanner = useSelector(getHasDismissedSystemConsoleLimitReached);
const openSalesLink = useOpenSalesLink(props.product?.sku === CloudProducts.PROFESSIONAL ? SalesInquiryIssue.UpgradeEnterprise : undefined);
const [openSalesLink] = useOpenSalesLink();
const openPricingModal = useOpenPricingModal();
const saveBool = useSaveBool();
if (hasDismissedBanner || !someLimitExceeded || !props.product || (props.product.sku !== CloudProducts.STARTER)) {

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

@@ -11,8 +11,6 @@ import {
getSubscriptionProduct,
} from 'mattermost-redux/selectors/entities/cloud';
import {SalesInquiryIssue} from 'selectors/cloud';
import {CloudProducts} from 'utils/constants';
import {asGBString, fallbackStarterLimits, hasSomeLimits} from 'utils/limits';
@@ -32,7 +30,7 @@ const Limits = (): JSX.Element | null => {
const subscriptionProduct = useSelector(getSubscriptionProduct);
const [cloudLimits, limitsLoaded] = useGetLimits();
const usage = useGetUsage();
const openSalesLink = useOpenSalesLink(SalesInquiryIssue.UpgradeEnterprise);
const [openSalesLink] = useOpenSalesLink();
const openPricingModal = useOpenPricingModal();
if (!subscriptionProduct || !limitsLoaded || !hasSomeLimits(cloudLimits)) {

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

@@ -10,7 +10,6 @@ import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurch
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import AnnouncementBar from 'components/announcement_bar/default_announcement_bar';
import {SalesInquiryIssue} from 'selectors/cloud';
import {getSubscriptionProduct as selectSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {savePreferences} from 'mattermost-redux/actions/preferences';
@@ -79,7 +78,7 @@ const ToYearlyNudgeBannerDismissable = () => {
const ToYearlyNudgeBanner = () => {
const {formatMessage} = useIntl();
const openSalesLink = useOpenSalesLink(SalesInquiryIssue.AboutPurchasing);
const [openSalesLink] = useOpenSalesLink();
const openPurchaseModal = useOpenCloudPurchaseModal({});
const product = useSelector(selectSubscriptionProduct);

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

@@ -7,6 +7,7 @@ import {useDispatch, useSelector} from 'react-redux';
import IconMessage from 'components/purchase_modal/icon_message';
import FullScreenModal from 'components/widgets/modals/full_screen_modal';
import {useOpenCloudZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import {closeModal} from 'actions/views/modals';
import {isModalOpen} from 'selectors/views/modals';
@@ -14,9 +15,6 @@ import {GlobalState} from 'types/store';
import './result_modal.scss';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {InquiryType} from 'selectors/cloud';
type Props = {
onHide?: () => void;
icon: JSX.Element;
@@ -33,7 +31,7 @@ type Props = {
export default function ResultModal(props: Props) {
const dispatch = useDispatch();
const openContactUs = useOpenSalesLink(undefined, InquiryType.Technical);
const [openContactSupport] = useOpenCloudZendeskSupportForm('Delete workspace', '');
const isResultModalOpen = useSelector((state: GlobalState) =>
isModalOpen(state, props.identifier),
@@ -64,14 +62,13 @@ export default function ResultModal(props: Props) {
buttonHandler={props.primaryButtonHandler}
className={'success'}
formattedTertiaryButonText={
props.contactSupportButtonVisible ?
props.contactSupportButtonVisible ? (
<FormattedMessage
id={'admin.billing.deleteWorkspace.resultModal.ContactSupport'}
defaultMessage={'Contact Support'}
/> :
undefined
/>) : undefined
}
tertiaryButtonHandler={props.contactSupportButtonVisible ? openContactUs : undefined}
tertiaryButtonHandler={props.contactSupportButtonVisible ? openContactSupport : undefined}
/>
</div>
</FullScreenModal>

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

@@ -17,7 +17,6 @@ describe('components/feature_discovery', () => {
<FeatureDiscovery
featureName='test'
minimumSKURequiredForFeature={LicenseSkus.Professional}
contactSalesLink='/sales'
titleID='translation.test.title'
titleDefault='Foo'
copyID='translation.test.copy'
@@ -46,7 +45,6 @@ describe('components/feature_discovery', () => {
<FeatureDiscovery
featureName='test'
minimumSKURequiredForFeature={LicenseSkus.Professional}
contactSalesLink='/sales'
titleID='translation.test.title'
titleDefault='Foo'
copyID='translation.test.copy'
@@ -76,7 +74,6 @@ describe('components/feature_discovery', () => {
<FeatureDiscovery
featureName='test'
minimumSKURequiredForFeature={LicenseSkus.Professional}
contactSalesLink='/sales'
titleID='translation.test.title'
titleDefault='Foo'
copyID='translation.test.copy'

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

@@ -6,9 +6,6 @@ import {FormattedMessage} from 'react-intl';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import {AnalyticsRow} from '@mattermost/types/admin';
import {ClientLicense} from '@mattermost/types/config';
import {EmbargoedEntityTrialError} from 'components/admin_console/license_settings/trial_banner/trial_banner';
import AlertBanner from 'components/alert_banner';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
@@ -17,17 +14,22 @@ import PurchaseLink from 'components/announcement_bar/purchase_link/purchase_lin
import ContactUsButton from 'components/announcement_bar/contact_sales/contact_us';
import PurchaseModal from 'components/purchase_modal';
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
import ExternalLink from 'components/external_link';
import {ModalIdentifiers, TELEMETRY_CATEGORIES, AboutLinks, LicenseLinks, LicenseSkus} from 'utils/constants';
import {FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS} from 'utils/cloud_utils';
import * as Utils from 'utils/utils';
import {goToMattermostContactSalesForm} from 'utils/contact_support_sales';
import {trackEvent} from 'actions/telemetry_actions';
import {ModalData} from 'types/actions';
import {ClientLicense} from '@mattermost/types/config';
import {AnalyticsRow} from '@mattermost/types/admin';
import {CloudCustomer} from '@mattermost/types/cloud';
import './feature_discovery.scss';
import ExternalLink from 'components/external_link';
type Props = {
featureName: string;
@@ -56,7 +58,7 @@ type Props = {
hadPrevCloudTrial: boolean;
isSubscriptionLoaded: boolean;
isPaidSubscription: boolean;
contactSalesLink: string;
customer?: CloudCustomer;
}
type State = {
@@ -97,6 +99,16 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
});
}
contactSalesFunc = () => {
const {customer, isCloud} = this.props;
const customerEmail = customer?.email || '';
const firstName = customer?.contact_first_name || '';
const lastName = customer?.contact_last_name || '';
const companyName = customer?.name || '';
const utmMedium = isCloud ? 'in-product-cloud' : 'in-product';
goToMattermostContactSalesForm(firstName, lastName, companyName, customerEmail, 'mattermost', utmMedium);
}
renderPostTrialCta = () => {
const {
minimumSKURequiredForFeature,
@@ -110,7 +122,7 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
data-testid='featureDiscovery_primaryCallToAction'
onClick={() => {
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
window.open(LicenseLinks.CONTACT_SALES, '_blank');
this.contactSalesFunc();
}}
>
<FormattedMessage
@@ -161,7 +173,6 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
hadPrevCloudTrial,
isPaidSubscription,
minimumSKURequiredForFeature,
contactSalesLink,
} = this.props;
const canRequestCloudFreeTrial = isCloud && !isCloudTrial && !hadPrevCloudTrial && !isPaidSubscription;
@@ -217,11 +228,10 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
onClick={() => {
if (isCloud) {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
window.open(contactSalesLink, '_blank');
} else {
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
window.open(LicenseLinks.CONTACT_SALES, '_blank');
}
this.contactSalesFunc();
}}
>
<FormattedMessage

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

@@ -7,11 +7,9 @@ import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {getPrevTrialLicense} from 'mattermost-redux/actions/admin';
import {getCloudSubscription} from 'mattermost-redux/actions/cloud';
import {Action, GenericAction} from 'mattermost-redux/types/actions';
import {checkHadPriorTrial} from 'mattermost-redux/selectors/entities/cloud';
import {checkHadPriorTrial, getCloudCustomer} from 'mattermost-redux/selectors/entities/cloud';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getCloudContactUsLink, InquiryType} from 'selectors/cloud';
import {ModalData} from 'types/actions';
import {GlobalState} from 'types/store';
@@ -30,7 +28,7 @@ function mapStateToProps(state: GlobalState) {
const isCloud = isCloudLicense(license);
const hasPriorTrial = checkHadPriorTrial(state);
const isCloudTrial = subscription?.is_free_trial === 'true';
const contactSalesLink = getCloudContactUsLink(state)(InquiryType.Sales);
const customer = getCloudCustomer(state);
return {
stats: state.entities.admin.analytics,
prevTrialLicense: state.entities.admin.prevTrialLicense,
@@ -39,7 +37,7 @@ function mapStateToProps(state: GlobalState) {
isSubscriptionLoaded: subscription !== undefined && subscription !== null,
hadPrevCloudTrial: hasPriorTrial,
isPaidSubscription: isCloud && license?.SkuShortName !== LicenseSkus.Starter && !isCloudTrial,
contactSalesLink,
customer,
};
}

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

@@ -2,13 +2,46 @@
// See LICENSE.txt for license information.
import React from 'react';
import {Provider} from 'react-redux';
import {LicenseSkus} from 'utils/constants';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import EnterpriseEditionRightPanel, {EnterpriseEditionProps} from './enterprise_edition_right_panel';
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
general: {
config: {
CWSURL: '',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_user'},
},
},
preferences: {
myPreferences: {},
},
cloud: {},
},
};
describe('components/admin_console/license_settings/enterprise_edition/enterprise_edition_right_panel', () => {
const license = {
IsLicensed: 'true',
@@ -28,8 +61,11 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
} as EnterpriseEditionProps;
test('should render for no Gov no Trial no Enterprise', () => {
const store = mockStore(initialState);
const wrapper = mountWithIntl(
<EnterpriseEditionRightPanel {...props}/>,
<Provider store={store}>
<EnterpriseEditionRightPanel {...props}/>
</Provider>,
);
expect(wrapper.find('.upgrade-title').text()).toEqual('Upgrade to the Enterprise Plan');
@@ -43,11 +79,14 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
});
test('should render for Gov no Trial no Enterprise', () => {
const store = mockStore(initialState);
const wrapper = mountWithIntl(
<EnterpriseEditionRightPanel
license={{...props.license, IsGovSku: 'true'}}
isTrialLicense={props.isTrialLicense}
/>,
<Provider store={store}>
<EnterpriseEditionRightPanel
license={{...props.license, IsGovSku: 'true'}}
isTrialLicense={props.isTrialLicense}
/>
</Provider>,
);
expect(wrapper.find('.upgrade-title').text()).toEqual('Upgrade to the Enterprise Gov Plan');
@@ -61,11 +100,14 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
});
test('should render for Enterprise no Trial', () => {
const store = mockStore(initialState);
const wrapper = mountWithIntl(
<EnterpriseEditionRightPanel
license={{...props.license, SkuShortName: LicenseSkus.Enterprise}}
isTrialLicense={props.isTrialLicense}
/>,
<Provider store={store}>
<EnterpriseEditionRightPanel
license={{...props.license, SkuShortName: LicenseSkus.Enterprise}}
isTrialLicense={props.isTrialLicense}
/>
</Provider>,
);
expect(wrapper.find('.upgrade-title').text()).toEqual('Need to increase your headcount?');
@@ -73,11 +115,14 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
});
test('should render for E20 no Trial', () => {
const store = mockStore(initialState);
const wrapper = mountWithIntl(
<EnterpriseEditionRightPanel
license={{...props.license, SkuShortName: LicenseSkus.E20}}
isTrialLicense={props.isTrialLicense}
/>,
<Provider store={store}>
<EnterpriseEditionRightPanel
license={{...props.license, SkuShortName: LicenseSkus.E20}}
isTrialLicense={props.isTrialLicense}
/>
</Provider>,
);
expect(wrapper.find('.upgrade-title').text()).toEqual('Need to increase your headcount?');
@@ -85,11 +130,14 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
});
test('should render for Trial no Gov', () => {
const store = mockStore(initialState);
const wrapper = mountWithIntl(
<EnterpriseEditionRightPanel
license={props.license}
isTrialLicense={true}
/>,
<Provider store={store}>
<EnterpriseEditionRightPanel
license={props.license}
isTrialLicense={true}
/>
</Provider>,
);
expect(wrapper.find('.upgrade-title').text()).toEqual('Purchase the Enterprise Plan');
@@ -97,11 +145,14 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
});
test('should render for Trial Gov', () => {
const store = mockStore(initialState);
const wrapper = mountWithIntl(
<EnterpriseEditionRightPanel
license={{...props.license, IsGovSku: 'true'}}
isTrialLicense={true}
/>,
<Provider store={store}>
<EnterpriseEditionRightPanel
license={{...props.license, IsGovSku: 'true'}}
isTrialLicense={true}
/>
</Provider>,
);
expect(wrapper.find('.upgrade-title').text()).toEqual('Purchase the Enterprise Gov Plan');

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

@@ -12,6 +12,37 @@ import mockStore from 'tests/test_store';
import RenewalLicenseCard from './renew_license_card';
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
general: {
config: {
CWSURL: '',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_user'},
},
},
preferences: {
myPreferences: {},
},
cloud: {},
},
};
const actImmediate = (wrapper: ReactWrapper) =>
act(
() =>
@@ -47,7 +78,7 @@ describe('components/RenewalLicenseCard', () => {
});
});
getRenewalLinkSpy.mockImplementation(() => promise);
const store = mockStore({});
const store = mockStore(initialState);
const wrapper = mountWithIntl(<Provider store={store}><RenewalLicenseCard {...props}/></Provider>);
// wait for the promise to resolve and component to update
@@ -64,7 +95,7 @@ describe('components/RenewalLicenseCard', () => {
reject(new Error('License cannot be renewed from portal'));
});
getRenewalLinkSpy.mockImplementation(() => promise);
const store = mockStore({});
const store = mockStore(initialState);
const wrapper = mountWithIntl(<Provider store={store}><RenewalLicenseCard {...props}/></Provider>);
// wait for the promise to resolve and component to update

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

@@ -1126,6 +1126,15 @@ export default class SchemaAdminSettings extends React.PureComponent {
}
if (setting.validate) {
if (setting.isHidden?.(this.props.config)) {
// MM-50952
// If the setting is hidden, then it is not being set in state so there is
// nothing to validate, and validation would fail anyways and prevent saving
// In practice, this only happens in custom cloud setup environments like RFQA
// where it sets things in the config file directly instead of in the environment
// (like cloud Mattermost does)
continue;
}
const result = setting.validate(this.state[setting.key]);
if (!result.isValid()) {
return false;

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

@@ -18,8 +18,9 @@ import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {GlobalState} from '@mattermost/types/store';
import {CloudLinks, ConsolePages, DocLinks, LicenseLinks} from 'utils/constants';
import {CloudLinks, ConsolePages, DocLinks} from 'utils/constants';
import {daysToLicenseExpire, isEnterpriseOrE20License, getIsStarterLicense} from '../../../utils/license_utils';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
export type DataModel = {
[key: string]: {
@@ -84,8 +85,10 @@ const useMetricsData = () => {
const isEnterpriseLicense = isEnterpriseOrE20License(license);
const isStarterLicense = getIsStarterLicense(license);
const [, contactSalesLink] = useOpenSalesLink();
const trialOrEnterpriseCtaConfig = {
configUrl: canStartTrial ? ConsolePages.LICENSE : LicenseLinks.CONTACT_SALES,
configUrl: canStartTrial ? ConsolePages.LICENSE : contactSalesLink,
configText: canStartTrial ? formatMessage({id: 'admin.reporting.workspace_optimization.cta.startTrial', defaultMessage: 'Start trial'}) : formatMessage({id: 'admin.reporting.workspace_optimization.cta.upgradeLicense', defaultMessage: 'Contact sales'}),
};

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

@@ -6,8 +6,9 @@ import React from 'react';
import {FormattedMessage} from 'react-intl';
import {trackEvent} from 'actions/telemetry_actions';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import './contact_us.scss';
import {LicenseLinks} from '../../../utils/constants';
export interface Props {
buttonTextElement?: JSX.Element;
@@ -16,10 +17,12 @@ export interface Props {
}
const ContactUsButton: React.FC<Props> = (props: Props) => {
const [openContactSales] = useOpenSalesLink();
const handleContactUsLinkClick = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
trackEvent('admin', props.eventID || 'in_trial_contact_sales');
window.open(LicenseLinks.CONTACT_SALES, '_blank');
openContactSales();
};
return (

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

@@ -15,7 +15,8 @@ import {savePreferences} from 'mattermost-redux/actions/preferences';
import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences';
import {PreferenceType} from '@mattermost/types/preferences';
import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck';
import {LicenseLinks, StatTypes, Preferences, AnnouncementBarTypes} from 'utils/constants';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {StatTypes, Preferences, AnnouncementBarTypes} from 'utils/constants';
import './overage_users_banner.scss';
@@ -34,6 +35,7 @@ const adminHasDismissed = ({preferenceName, overagePreferences, isWarningBanner}
};
const OverageUsersBanner = () => {
const [openContactSales] = useOpenSalesLink();
const dispatch = useDispatch();
const stats = useSelector((state: GlobalState) => state.entities.admin.analytics) || {};
const isAdmin = useSelector(isCurrentUserSystemAdmin);
@@ -90,7 +92,7 @@ const OverageUsersBanner = () => {
const handleContactSalesClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
trackEventFn('Contact Sales');
window.open(LicenseLinks.CONTACT_SALES, '_blank');
openContactSales();
};
const handleClick = isExpandable ? handleUpdateSeatsSelfServeClick : handleContactSalesClick;

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

@@ -7,7 +7,7 @@ import {fireEvent, screen} from '@testing-library/react';
import {DeepPartial} from '@mattermost/types/utilities';
import {GlobalState} from 'types/store';
import {General} from 'mattermost-redux/constants';
import {LicenseLinks, OverActiveUserLimits, Preferences, StatTypes} from 'utils/constants';
import {OverActiveUserLimits, Preferences, StatTypes} from 'utils/constants';
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {trackEvent} from 'actions/telemetry_actions';
@@ -249,7 +249,10 @@ describe('components/overage_users_banner', () => {
fireEvent.click(screen.getByText(contactSalesTextLink));
expect(windowSpy).toBeCalledTimes(1);
expect(windowSpy).toBeCalledWith(LicenseLinks.CONTACT_SALES, '_blank');
// only the email is encoded and other params are empty. See logic for useOpenSalesLink hook
const salesLinkWithEncodedParams = 'https://mattermost.com/contact-sales/?qk=&qp=&qw=&qx=dGVzdEBtYXR0ZXJtb3N0LmNvbQ==&utm_source=mattermost&utm_medium=in-product';
expect(windowSpy).toBeCalledWith(salesLinkWithEncodedParams, '_blank');
expect(trackEvent).toBeCalledTimes(1);
expect(trackEvent).toBeCalledWith('insights', 'click_true_up_warning', {
cta: 'Contact Sales',
@@ -368,7 +371,10 @@ describe('components/overage_users_banner', () => {
fireEvent.click(screen.getByText(contactSalesTextLink));
expect(windowSpy).toBeCalledTimes(1);
expect(windowSpy).toBeCalledWith(LicenseLinks.CONTACT_SALES, '_blank');
// only the email is encoded and other params are empty. See logic for useOpenSalesLink hook
const salesLinkWithEncodedParams = 'https://mattermost.com/contact-sales/?qk=&qp=&qw=&qx=dGVzdEBtYXR0ZXJtb3N0LmNvbQ==&utm_source=mattermost&utm_medium=in-product';
expect(windowSpy).toBeCalledWith(salesLinkWithEncodedParams, '_blank');
expect(trackEvent).toBeCalledTimes(1);
expect(trackEvent).toBeCalledWith('insights', 'click_true_up_error', {
cta: 'Contact Sales',

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

@@ -3,13 +3,46 @@
import React from 'react';
import {ReactWrapper} from 'enzyme';
import {Provider} from 'react-redux';
import {act} from 'react-dom/test-utils';
import {Client4} from 'mattermost-redux/client';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import RenewalLink from './renewal_link';
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
general: {
config: {
CWSURL: '',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_user'},
},
},
preferences: {
myPreferences: {},
},
cloud: {},
},
};
const actImmediate = (wrapper: ReactWrapper) =>
act(
() =>
@@ -40,7 +73,8 @@ describe('components/RenewalLink', () => {
});
});
getRenewalLinkSpy.mockImplementation(() => promise);
const wrapper = mountWithIntl(<RenewalLink {...props}/>);
const store = mockStore(initialState);
const wrapper = mountWithIntl(<Provider store={store}><RenewalLink {...props}/></Provider>);
// wait for the promise to resolve and component to update
await actImmediate(wrapper);
@@ -54,7 +88,8 @@ describe('components/RenewalLink', () => {
reject(new Error('License cannot be renewed from portal'));
});
getRenewalLinkSpy.mockImplementation(() => promise);
const wrapper = mountWithIntl(<RenewalLink {...props}/>);
const store = mockStore(initialState);
const wrapper = mountWithIntl(<Provider store={store}><RenewalLink {...props}/></Provider>);
// wait for the promise to resolve and component to update
await actImmediate(wrapper);

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

@@ -11,9 +11,9 @@ import {trackEvent} from 'actions/telemetry_actions';
import {ModalData} from 'types/actions';
import {
LicenseLinks,
ModalIdentifiers,
} from 'utils/constants';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import NoInternetConnection from '../no_internet_connection/no_internet_connection';
@@ -31,6 +31,9 @@ export interface RenewalLinkProps {
const RenewalLink = (props: RenewalLinkProps) => {
const [renewalLink, setRenewalLink] = useState('');
const [manualInterventionRequired, setManualInterventionRequired] = useState(false);
const [openContactSales] = useOpenSalesLink();
useEffect(() => {
Client4.getRenewalLink().then(({renewal_link: renewalLinkParam}) => {
try {
@@ -55,7 +58,7 @@ const RenewalLink = (props: RenewalLinkProps) => {
}
window.open(renewalLink, '_blank');
} else if (manualInterventionRequired) {
window.open(LicenseLinks.CONTACT_SALES, '_blank');
openContactSales();
} else {
showConnectionErrorModal();
}

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

@@ -1,63 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/app_bar/app_bar should match snapshot on mount 1`] = `
.c0:last-child,
.c0:first-child {
display: none;
}
<AppBar>
<div
className="app-bar"
>
<AppBarPluginComponent
component={
Object {
"action": [MockFunction],
"icon": "fallback_component",
"id": "the_component_id",
"pluginId": "playbooks",
"tooltipText": "Playbooks Tooltip",
}
}
key="the_component_id"
<div
className="app-bar__top"
>
<OverlayTrigger
defaultOverlayShown={false}
delayShow={400}
overlay={
<Tooltip
bsClass="tooltip"
id="pluginTooltip-app-bar-icon-playbooks"
placement="right"
>
<span>
Playbooks Tooltip
</span>
</Tooltip>
}
placement="left"
trigger={
Array [
"hover",
"focus",
]
<AppBarPluginComponent
component={
Object {
"action": [MockFunction],
"icon": "fallback_component",
"id": "the_component_id",
"pluginId": "playbooks",
"tooltipText": "Playbooks Tooltip",
}
}
key="the_component_id"
>
<OverlayTrigger
defaultOverlayShown={false}
delayShow={400}
overlay={
<OverlayWrapper
<Tooltip
bsClass="tooltip"
id="pluginTooltip-app-bar-icon-playbooks"
intl={null}
placement="right"
>
<span>
Playbooks Tooltip
</span>
</OverlayWrapper>
</Tooltip>
}
placement="left"
trigger={
@@ -67,77 +42,73 @@ exports[`components/app_bar/app_bar should match snapshot on mount 1`] = `
]
}
>
<div
className="app-bar__icon"
id="app-bar-icon-playbooks"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
<OverlayTrigger
defaultOverlayShown={false}
delayShow={400}
overlay={
<OverlayWrapper
bsClass="tooltip"
id="pluginTooltip-app-bar-icon-playbooks"
intl={null}
placement="right"
>
<span>
Playbooks Tooltip
</span>
</OverlayWrapper>
}
placement="left"
trigger={
Array [
"hover",
"focus",
]
}
>
<div
className="app-bar__old-icon app-bar__icon-inner app-bar__icon-inner--centered"
className="app-bar__icon"
id="app-bar-icon-playbooks"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
>
fallback_component
<div
className="app-bar__old-icon app-bar__icon-inner app-bar__icon-inner--centered"
>
fallback_component
</div>
</div>
</div>
</OverlayTrigger>
</OverlayTrigger>
</OverlayTrigger>
</AppBarPluginComponent>
<_StyledHr
className="app-bar__divider"
key="divider"
>
</AppBarPluginComponent>
<hr
className="c0 app-bar__divider"
className="app-bar__divider"
key="divider"
/>
</_StyledHr>
<AppBarBinding
binding={
Object {
"app_id": "com.mattermost.zendesk",
"label": "Create Subscription",
}
}
key="com.mattermost.zendesk_Create Subscription"
>
<OverlayTrigger
defaultOverlayShown={false}
delayShow={400}
overlay={
<Tooltip
bsClass="tooltip"
id="tooltip-app-bar-icon-com.mattermost.zendesk"
placement="right"
>
<span>
Create Subscription
</span>
</Tooltip>
}
placement="left"
trigger={
Array [
"hover",
"focus",
]
<AppBarBinding
binding={
Object {
"app_id": "com.mattermost.zendesk",
"label": "Create Subscription",
}
}
key="com.mattermost.zendesk_Create Subscription"
>
<OverlayTrigger
defaultOverlayShown={false}
delayShow={400}
overlay={
<OverlayWrapper
<Tooltip
bsClass="tooltip"
id="tooltip-app-bar-icon-com.mattermost.zendesk"
intl={null}
placement="right"
>
<span>
Create Subscription
</span>
</OverlayWrapper>
</Tooltip>
}
placement="left"
trigger={
@@ -147,25 +118,49 @@ exports[`components/app_bar/app_bar should match snapshot on mount 1`] = `
]
}
>
<div
aria-label="Create Subscription"
className="app-bar__icon"
id="app-bar-icon-com.mattermost.zendesk"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
<OverlayTrigger
defaultOverlayShown={false}
delayShow={400}
overlay={
<OverlayWrapper
bsClass="tooltip"
id="tooltip-app-bar-icon-com.mattermost.zendesk"
intl={null}
placement="right"
>
<span>
Create Subscription
</span>
</OverlayWrapper>
}
placement="left"
trigger={
Array [
"hover",
"focus",
]
}
>
<div
className="app-bar__icon-inner"
aria-label="Create Subscription"
className="app-bar__icon"
id="app-bar-icon-com.mattermost.zendesk"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<img />
<div
className="app-bar__icon-inner"
>
<img />
</div>
</div>
</div>
</OverlayTrigger>
</OverlayTrigger>
</OverlayTrigger>
</AppBarBinding>
</AppBarBinding>
</div>
</div>
</AppBar>
`;

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

@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
@import 'utils/mixins';
$app-bar-icon-size: 24px;
$app-bar-width: 48px;
@@ -10,129 +12,143 @@ $app-bar-width: 48px;
display: none;
}
position: relative;
width: $app-bar-width;
padding-top: 16px;
display: flex;
min-height: 0;
flex-flow: column;
border-left: solid 1px rgba(var(--center-channel-color-rgb), 0.12);
background-color: var(--center-channel-bg);
-ms-overflow-style: none;
overflow-x: hidden;
overflow-y: scroll;
scrollbar-width: none;
text-align: center;
&::before {
position: absolute;
top: 0;
display: block;
width: 100%;
height: 100%;
border-left: solid 1px rgba(var(--center-channel-color-rgb), 0.12);
background-color: rgba(var(--center-channel-color-rgb), 0.04);
content: '';
}
.app-bar__icon {
&__top {
position: relative;
// Render App Bar icons on top of the RHS background div
//(see `@media screen and (min-width: 769px) > #sidebar-right` in sass/layout/_sidebar-right.scss)
z-index: 21;
width: 100%;
border-left: none;
margin-bottom: 16px;
cursor: pointer;
display: flex;
width: $app-bar-width;
flex: 1;
flex-flow: column;
padding-top: 16px;
background-color: rgba(var(--center-channel-color-rgb), 0.04);
-ms-overflow-style: none;
overflow-x: hidden;
overflow-y: scroll;
scrollbar-width: none;
text-align: center;
&--active {
&::before {
position: absolute;
top: 0;
left: 0;
width: 3px;
height: $app-bar-icon-size;
background-color: var(--sidebar-text-active-border);
border-radius: 0 2px 2px 0;
content: '';
.app-bar__icon {
position: relative;
// Render App Bar icons on top of the RHS background div
//(see `@media screen and (min-width: 769px) > #sidebar-right` in sass/layout/_sidebar-right.scss)
z-index: 21;
width: 100%;
border-left: none;
margin-bottom: 16px;
cursor: pointer;
&--active {
&::before {
position: absolute;
top: 0;
left: 0;
width: 3px;
height: $app-bar-icon-size;
background-color: var(--sidebar-text-active-border);
border-radius: 0 2px 2px 0;
content: '';
}
.app-bar__icon-inner,
span:not(.pulsating_dot) {
// if we want to show a tourtip/pulsating dot in any of the app bar icons, these styles must be ommitted when span.pulsating_dot
box-shadow: 0 0 0 2px var(--sidebar-text-active-border);
&:hover {
box-shadow: 0 0 0 2px rgba(var(--sidebar-text-active-border-rgb), 0.92) !important;
}
}
}
.app-bar__icon-inner,
span:not(.pulsating_dot) {
// if we want to show a tourtip/pulsating dot in any of the app bar icons, these styles must be ommitted when span.pulsating_dot
box-shadow: 0 0 0 2px var(--sidebar-text-active-border);
&:hover {
box-shadow: 0 0 0 2px rgba(var(--sidebar-text-active-border-rgb), 0.92) !important;
}
}
}
.app-bar__icon-inner,
span:not(.pulsating_dot) {
display: block;
overflow: hidden;
width: $app-bar-icon-size;
height: $app-bar-icon-size;
margin: 0 auto;
border-radius: 50%;
line-height: 1;
&:hover {
box-shadow: 0 0 0 2px rgba(var(--center-channel-color-rgb), 0.16);
}
img {
display: block;
overflow: hidden;
width: $app-bar-icon-size;
height: $app-bar-icon-size;
margin: 0 auto;
border-radius: 50%;
}
}
line-height: 1;
span:not(.pulsating_dot) {
padding: 2px;
background-color: white;
fill: var(--button-bg);
font-size: 14px;
line-height: 20px;
vertical-align: middle;
&:hover {
box-shadow: 0 0 0 2px rgba(var(--center-channel-color-rgb), 0.16);
}
&.CompassIcon,
&.icon-brand-zoom {
font-size: 20px;
&::before {
margin: 0 0 0 0.5px;
img {
width: $app-bar-icon-size;
height: $app-bar-icon-size;
border-radius: 50%;
}
}
}
.app-bar__old-icon {
color: rgba(var(--center-channel-color-rgb), 0.56);
span:not(.pulsating_dot) {
padding: 2px;
background-color: white;
fill: var(--button-bg);
font-size: 14px;
line-height: 20px;
vertical-align: middle;
&:hover,
&--active {
color: rgba(var(--center-channel-color-rgb), 0.72);
&.CompassIcon,
&.icon-brand-zoom {
font-size: 20px;
&::before {
margin: 0 0 0 0.5px;
}
}
}
.app-bar__old-icon {
color: rgba(var(--center-channel-color-rgb), 0.56);
&:hover,
&--active {
color: rgba(var(--center-channel-color-rgb), 0.72);
}
}
.app-bar__icon-inner--centered {
display: grid;
place-items: center;
}
}
.app-bar__icon-inner--centered {
display: grid;
place-items: center;
.app-bar__divider {
width: 28px;
border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
margin-top: 14px;
margin-bottom: 14px;
}
.app-bar__icon.channel-header__icon--active {
background: rgba(var(--button-bg-rgb), 0.08);
color: var(--button-bg);
fill: var(--button-bg);
}
}
.app-bar__divider {
width: 28px;
border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
margin-top: 14px;
margin-bottom: 14px;
}
&__bottom {
display: flex;
flex-flow: column;
align-items: center;
padding-top: 24px;
padding-bottom: 36px;
background-color: rgba(var(--center-channel-color-rgb), 0.04);
.app-bar__icon.channel-header__icon--active {
background: rgba(var(--button-bg-rgb), 0.08);
color: var(--button-bg);
fill: var(--button-bg);
.app_bar__marketplace_button {
@include icon-button;
@include icon-button-small-compact;
}
}
}
// This style is defined outside the .app-bar block above because it doesn't seem to work when defined there
.app-bar::-webkit-scrollbar {
.app-bar__top::-webkit-scrollbar {
display: none;
}

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

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {mount} from 'enzyme';
import {mount, shallow} from 'enzyme';
import 'jest-styled-components';
import {AppBinding} from '@mattermost/types/apps';
@@ -10,6 +10,7 @@ import {AppBinding} from '@mattermost/types/apps';
import {PluginComponent} from 'types/store/plugins';
import {GlobalState} from 'types/store';
import {Permissions} from 'mattermost-redux/constants';
import {AppBindingLocations} from 'mattermost-redux/constants/apps';
import AppBar from './app_bar';
@@ -82,6 +83,21 @@ describe('components/app_bar/app_bar', () => {
myPreferences: {
},
} as any,
users: {
currentUserId: 'user1',
profiles: {
user1: {
roles: 'system_user',
},
},
} as any,
roles: {
roles: {
system_user: {
permissions: [],
},
},
} as any,
},
} as GlobalState;
});
@@ -134,4 +150,48 @@ describe('components/app_bar/app_bar', () => {
expect(wrapper).toMatchSnapshot();
});
test('should not show marketplace if disabled or user does not have SYSCONSOLE_WRITE_PLUGINS permission', async () => {
mockState.entities.general = {
config: {
EnableAppBar: 'true',
FeatureFlagAppsEnabled: 'true',
EnableMarketplace: 'true',
PluginsEnabled: 'true',
},
} as any;
const wrapper = shallow(
<AppBar/>,
);
expect(wrapper.find('AppBarMarketplace').exists()).toEqual(false);
});
test('should show marketplace if enabled and user has SYSCONSOLE_WRITE_PLUGINS permission', async () => {
mockState.entities.general = {
config: {
EnableAppBar: 'true',
FeatureFlagAppsEnabled: 'true',
EnableMarketplace: 'true',
PluginsEnabled: 'true',
},
} as any;
mockState.entities.roles = {
roles: {
system_user: {
permissions: [
Permissions.SYSCONSOLE_WRITE_PLUGINS,
],
},
},
} as any;
const wrapper = shallow(
<AppBar/>,
);
expect(wrapper.find('AppBarMarketplace').exists()).toEqual(true);
});
});

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

@@ -12,8 +12,15 @@ import {getAppBarAppBindings} from 'mattermost-redux/selectors/entities/apps';
import {getAppBarPluginComponents, getChannelHeaderPluginComponents, shouldShowAppBar} from 'selectors/plugins';
import {suitePluginIds} from 'utils/constants';
import {Permissions} from 'mattermost-redux/constants';
import {isMarketplaceEnabled} from 'mattermost-redux/selectors/entities/general';
import {haveICurrentTeamPermission} from 'mattermost-redux/selectors/entities/roles';
import {GlobalState} from '@mattermost/types/store';
import AppBarPluginComponent, {isAppBarPluginComponent} from './app_bar_plugin_component';
import AppBarBinding, {isAppBinding} from './app_bar_binding';
import AppBarMarketplace from './app_bar_marketplace';
import './app_bar.scss';
@@ -24,6 +31,10 @@ export default function AppBar() {
const currentProduct = useCurrentProduct();
const currentProductId = useCurrentProductId();
const enabled = useSelector(shouldShowAppBar);
const canOpenMarketplace = useSelector((state: GlobalState) => (
isMarketplaceEnabled(state) &&
haveICurrentTeamPermission(state, Permissions.SYSCONSOLE_WRITE_PLUGINS)
));
if (
!enabled ||
@@ -40,11 +51,15 @@ export default function AppBar() {
const items: ReactNode[] = [
...coreProductComponents,
divider,
getDivider(coreProductComponents.length, (pluginComponents.length + channelHeaderComponents.length + appBarBindings.length)),
...pluginComponents,
...channelHeaderComponents,
...appBarBindings,
].map((x) => {
if (!x) {
return x;
}
if (isAppBarPluginComponent(x)) {
if (!inScope(x.supportedProductIds ?? null, currentProductId, currentProduct?.pluginId)) {
return null;
@@ -69,26 +84,23 @@ export default function AppBar() {
return x;
});
if (!items.some((x) => Boolean(x) && x !== divider)) {
return null;
}
return (
<div className={'app-bar'}>
{items}
<div className={'app-bar__top'}>
{items}
</div>
{canOpenMarketplace && (
<div className='app-bar__bottom'>
<AppBarMarketplace/>
</div>
)}
</div>
);
}
const divider = (
const getDivider = (beforeCount: number, afterCount: number) => (beforeCount && afterCount ? (
<hr
key='divider'
className={'app-bar__divider'}
// eslint-disable-next-line react/no-unknown-property
css={`
:last-child, :first-child {
display: none;
}
`}
className='app-bar__divider'
/>
);
) : null);

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

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useDispatch} from 'react-redux';
import {useIntl} from 'react-intl';
import {Tooltip} from 'react-bootstrap';
import Icon from '@mattermost/compass-components/foundations/icon';
import {openModal} from 'actions/views/modals';
import MarketplaceModal from 'components/plugin_marketplace';
import OverlayTrigger from 'components/overlay_trigger';
import {Constants, ModalIdentifiers} from 'utils/constants';
const AppBarMarketplace = () => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const handleOpenMarketplace = useCallback(() => {
dispatch(
openModal({
modalId: ModalIdentifiers.PLUGIN_MARKETPLACE,
dialogType: MarketplaceModal,
dialogProps: {openedFrom: 'app_bar'},
}),
);
}, [dispatch]);
const label = formatMessage({id: 'app_bar.marketplace', defaultMessage: 'App Marketplace'});
return (
<OverlayTrigger
trigger={['hover', 'focus']}
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='left'
overlay={(
<Tooltip id='tooltip-app-bar-marketplace'>
<span>{label}</span>
</Tooltip>
)}
>
<button
key='app_bar_marketplace'
className='app_bar__marketplace_button'
aria-label={label}
onClick={handleOpenMarketplace}
>
<Icon
size={16}
glyph={'view-grid-plus-outline'}
/>
</button>
</OverlayTrigger>
);
};
export default AppBarMarketplace;

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

@@ -3,8 +3,11 @@
import React from 'react';
import {Channel, ChannelStats} from '@mattermost/types/channels';
import {act} from '@testing-library/react';
import {renderWithIntl} from 'tests/react_testing_utils';
import {Channel, ChannelStats} from '@mattermost/types/channels';
import {UserProfile} from '@mattermost/types/users';
import {Team} from '@mattermost/types/teams';
@@ -40,6 +43,7 @@ describe('channel_info_rhs', () => {
showChannelFiles: jest.fn(),
showPinnedPosts: jest.fn(),
showChannelMembers: jest.fn(),
getChannelStats: jest.fn().mockImplementation(() => Promise.resolve({data: {}})),
},
};
let props = {...OriginalProps};
@@ -49,20 +53,24 @@ describe('channel_info_rhs', () => {
});
describe('about area', () => {
test('should be editable', () => {
test('should be editable', async () => {
renderWithIntl(
<ChannelInfoRHS
{...props}
/>,
);
await act(async () => {
props.actions.getChannelStats();
});
expect(mockAboutArea).toHaveBeenCalledWith(
expect.objectContaining({
canEditChannelProperties: true,
}),
);
});
test('should not be editable in archived channel', () => {
test('should not be editable in archived channel', async () => {
props.isArchived = true;
renderWithIntl(
@@ -71,6 +79,10 @@ describe('channel_info_rhs', () => {
/>,
);
await act(async () => {
props.actions.getChannelStats();
});
expect(mockAboutArea).toHaveBeenCalledWith(
expect.objectContaining({
canEditChannelProperties: false,

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

@@ -64,6 +64,7 @@ export interface Props {
showChannelFiles: (channelId: string) => void;
showPinnedPosts: (channelId: string | undefined) => void;
showChannelMembers: (channelId: string) => void;
getChannelStats: (channelId: string) => Promise<{data: ChannelStats}>;
};
}
@@ -192,6 +193,7 @@ const ChannelInfoRhs = ({
showChannelFiles: actions.showChannelFiles,
showPinnedPosts: actions.showPinnedPosts,
showChannelMembers: actions.showChannelMembers,
getChannelStats: actions.getChannelStats,
}}
/>
</div>

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

@@ -16,7 +16,7 @@ import {Constants, ModalIdentifiers} from 'utils/constants';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import {getIsMobileView} from 'selectors/views/browser';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {unfavoriteChannel, favoriteChannel} from 'mattermost-redux/actions/channels';
import {unfavoriteChannel, favoriteChannel, getChannelStats} from 'mattermost-redux/actions/channels';
import {muteChannel, unmuteChannel} from 'actions/channel_actions';
import {openModal} from 'actions/views/modals';
import {getDisplayNameByUser, getUserIdFromChannelId} from 'utils/utils';
@@ -92,6 +92,7 @@ function mapDispatchToProps(dispatch: Dispatch<AnyAction>) {
showChannelFiles,
showPinnedPosts,
showChannelMembers,
getChannelStats,
}, dispatch),
};
}

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

@@ -2,12 +2,13 @@
// See LICENSE.txt for license information.
import React from 'react';
import {fireEvent, screen} from '@testing-library/react';
import {act, fireEvent, screen} from '@testing-library/react';
import {Channel, ChannelStats} from '@mattermost/types/channels';
import {renderWithIntl} from 'tests/react_testing_utils';
import Constants from 'utils/constants';
import {Channel, ChannelStats} from '@mattermost/types/channels';
import Menu from './menu';
describe('channel_info_rhs/menu', () => {
@@ -20,6 +21,7 @@ describe('channel_info_rhs/menu', () => {
showChannelFiles: jest.fn(),
showPinnedPosts: jest.fn(),
showChannelMembers: jest.fn(),
getChannelStats: jest.fn().mockImplementation(() => Promise.resolve({data: {files_count: 3, pinnedpost_count: 12, member_count: 32}})),
},
};
@@ -29,10 +31,11 @@ describe('channel_info_rhs/menu', () => {
showChannelFiles: jest.fn(),
showPinnedPosts: jest.fn(),
showChannelMembers: jest.fn(),
getChannelStats: jest.fn().mockImplementation(() => Promise.resolve({data: {files_count: 3, pinnedpost_count: 12, member_count: 32}})),
};
});
test('should display notifications preferences', () => {
test('should display notifications preferences', async () => {
const props = {...defaultProps};
props.actions.openNotificationSettings = jest.fn();
@@ -42,13 +45,17 @@ describe('channel_info_rhs/menu', () => {
/>,
);
await act(async () => {
props.actions.getChannelStats();
});
expect(screen.getByText('Notification Preferences')).toBeInTheDocument();
fireEvent.click(screen.getByText('Notification Preferences'));
expect(props.actions.openNotificationSettings).toHaveBeenCalled();
});
test('should NOT display notifications preferences in a DM', () => {
test('should NOT display notifications preferences in a DM', async () => {
const props = {
...defaultProps,
channel: {type: Constants.DM_CHANNEL} as Channel,
@@ -60,10 +67,14 @@ describe('channel_info_rhs/menu', () => {
/>,
);
await act(async () => {
props.actions.getChannelStats();
});
expect(screen.queryByText('Notification Preferences')).not.toBeInTheDocument();
});
test('should NOT display notifications preferences in an archived channel', () => {
test('should NOT display notifications preferences in an archived channel', async () => {
const props = {
...defaultProps,
isArchived: true,
@@ -75,10 +86,14 @@ describe('channel_info_rhs/menu', () => {
/>,
);
await act(async () => {
props.actions.getChannelStats();
});
expect(screen.queryByText('Notification Preferences')).not.toBeInTheDocument();
});
test('should display the number of files', () => {
test('should display the number of files', async () => {
const props = {...defaultProps};
props.actions.showChannelFiles = jest.fn();
@@ -88,6 +103,10 @@ describe('channel_info_rhs/menu', () => {
/>,
);
await act(async () => {
props.actions.getChannelStats();
});
const fileItem = screen.getByText('Files');
expect(fileItem).toBeInTheDocument();
expect(fileItem.parentElement).toHaveTextContent('3');
@@ -96,7 +115,7 @@ describe('channel_info_rhs/menu', () => {
expect(props.actions.showChannelFiles).toHaveBeenCalled();
});
test('should display the pinned messages', () => {
test('should display the pinned messages', async () => {
const props = {...defaultProps};
props.actions.showPinnedPosts = jest.fn();
@@ -106,6 +125,10 @@ describe('channel_info_rhs/menu', () => {
/>,
);
await act(async () => {
props.actions.getChannelStats();
});
const fileItem = screen.getByText('Pinned Messages');
expect(fileItem).toBeInTheDocument();
expect(fileItem.parentElement).toHaveTextContent('12');
@@ -114,7 +137,7 @@ describe('channel_info_rhs/menu', () => {
expect(props.actions.showPinnedPosts).toHaveBeenCalled();
});
test('should display members', () => {
test('should display members', async () => {
const props = {...defaultProps};
props.actions.showChannelMembers = jest.fn();
@@ -124,6 +147,10 @@ describe('channel_info_rhs/menu', () => {
/>,
);
await act(async () => {
props.actions.getChannelStats();
});
const membersItem = screen.getByText('Members');
expect(membersItem).toBeInTheDocument();
expect(membersItem.parentElement).toHaveTextContent('32');
@@ -132,7 +159,7 @@ describe('channel_info_rhs/menu', () => {
expect(props.actions.showChannelMembers).toHaveBeenCalled();
});
test('should NOT display members in DM', () => {
test('should NOT display members in DM', async () => {
const props = {
...defaultProps,
channel: {type: Constants.DM_CHANNEL} as Channel,
@@ -144,6 +171,10 @@ describe('channel_info_rhs/menu', () => {
/>,
);
await act(async () => {
props.actions.getChannelStats();
});
const membersItem = screen.queryByText('Members');
expect(membersItem).not.toBeInTheDocument();
});

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

@@ -1,12 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useEffect, useState} from 'react';
import styled from 'styled-components';
import {useIntl} from 'react-intl';
import {Constants} from 'utils/constants';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import {Channel, ChannelStats} from '@mattermost/types/channels';
const MenuItemContainer = styled.div`
@@ -32,6 +34,9 @@ const RightSide = styled.div`
const Badge = styled.div`
font-size: 12px;
line-height: 18px;
width: 20px;
display: flex;
place-content: center;
`;
interface MenuItemProps {
@@ -39,7 +44,7 @@ interface MenuItemProps {
icon: JSX.Element;
text: string;
opensSubpanel?: boolean;
badge?: string|number;
badge?: string|number|JSX.Element;
onClick: () => void;
}
@@ -94,14 +99,26 @@ interface MenuProps {
showChannelFiles: (channelId: string) => void;
showPinnedPosts: (channelId: string | undefined) => void;
showChannelMembers: (channelId: string) => void;
getChannelStats: (channelId: string) => Promise<{data: ChannelStats}>;
};
}
const Menu = ({channel, channelStats, isArchived, className, actions}: MenuProps) => {
const {formatMessage} = useIntl();
const [loadingStats, setLoadingStats] = useState(true);
const showNotificationPreferences = channel.type !== Constants.DM_CHANNEL && !isArchived;
const showMembers = channel.type !== Constants.DM_CHANNEL;
const fileCount = channelStats?.files_count >= 0 ? channelStats?.files_count : 0;
useEffect(() => {
actions.getChannelStats(channel.id).then(() => {
setLoadingStats(false);
});
return () => {
setLoadingStats(true);
};
}, [channel.id]);
return (
<div
@@ -135,7 +152,7 @@ const Menu = ({channel, channelStats, isArchived, className, actions}: MenuProps
icon={<i className='icon icon-file-text-outline'/>}
text={formatMessage({id: 'channel_info_rhs.menu.files', defaultMessage: 'Files'})}
opensSubpanel={true}
badge={channelStats?.files_count}
badge={loadingStats ? <LoadingSpinner/> : fileCount}
onClick={() => actions.showChannelFiles(channel.id)}
/>
</div>

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

@@ -0,0 +1,37 @@
.shipping-address-section {
display: flex;
align-content: flex-start;
padding-bottom: 24px;
font-weight: normal;
button.no-style {
padding-left: 0;
border: none;
background: transparent;
outline: unset;
text-align: left;
&:focus {
outline: unset;
}
}
#address-same-than-billing-address {
width: 17px;
height: 17px;
flex-shrink: 0;
}
.Form-checkbox-label {
padding-left: 12px;
cursor: default;
font-family: 'Open Sans', sans-serif;
vertical-align: middle;
}
.billing_address_btn_text {
color: var(--center-channel-color);
font-family: 'Open Sans', sans-serif;
font-weight: bold;
}
}

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

@@ -0,0 +1,45 @@
// 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 './choose_different_shipping.scss';
interface Props {
shippingIsSame: boolean;
setShippingIsSame: (different: boolean) => void;
}
export default function ChooseDifferentShipping(props: Props) {
const intl = useIntl();
const toggle = () => props.setShippingIsSame(!props.shippingIsSame);
return (
<div className='shipping-address-section'>
<input
id='address-same-than-billing-address'
className='Form-checkbox-input'
name='terms'
type='checkbox'
checked={props.shippingIsSame}
onChange={toggle}
/>
<span className='Form-checkbox-label'>
<button
onClick={toggle}
type='button'
className='no-style'
>
<span className='billing_address_btn_text'>
{intl.formatMessage({
id: 'admin.billing.subscription.complianceScreenShippingSameAsBilling',
defaultMessage:
'My shipping address is the same as my billing address',
})}
</span>
</button>
</span>
</div>
);
}

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

@@ -13,9 +13,8 @@ import PaymentFailedSvg from 'components/common/svg_images_components/payment_fa
import IconMessage from 'components/purchase_modal/icon_message';
import FullScreenModal from 'components/widgets/modals/full_screen_modal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {useOpenCloudZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import {InquiryType} from 'selectors/cloud';
import {closeModal} from 'actions/views/modals';
import {ModalIdentifiers} from 'utils/constants';
import {isModalOpen} from 'selectors/views/modals';
@@ -31,7 +30,8 @@ type Props = {
function ErrorModal(props: Props) {
const dispatch = useDispatch();
const subscriptionProduct = useSelector(getSubscriptionProduct);
const openContactUs = useOpenSalesLink(undefined, InquiryType.Technical);
const [openContactSupport] = useOpenCloudZendeskSupportForm('Cloud Subscription', '');
const isSuccessModalOpen = useSelector((state: GlobalState) =>
isModalOpen(state, ModalIdentifiers.ERROR_MODAL),
@@ -97,7 +97,7 @@ function ErrorModal(props: Props) {
}
/>
}
tertiaryButtonHandler={openContactUs}
tertiaryButtonHandler={openContactSupport}
buttonHandler={onBackButtonPress}
className={'success'}
/>

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

@@ -3,11 +3,35 @@
import {useSelector} from 'react-redux';
import {getCloudContactUsLink, InquiryType, SalesInquiryIssue} from 'selectors/cloud';
import {getCloudCustomer, isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {buildMMURL, goToMattermostContactSalesForm} from 'utils/contact_support_sales';
import {LicenseLinks} from 'utils/constants';
export default function useOpenSalesLink(issue?: SalesInquiryIssue, inquireType: InquiryType = InquiryType.Sales) {
const contactSalesLink = useSelector(getCloudContactUsLink)(inquireType, issue);
export default function useOpenSalesLink(): [() => void, string] {
const isCloud = useSelector(isCurrentLicenseCloud);
const customer = useSelector(getCloudCustomer);
const currentUser = useSelector(getCurrentUser);
let customerEmail = '';
let firstName = '';
let lastName = '';
let companyName = '';
const utmSource = 'mattermost';
let utmMedium = 'in-product';
return () => window.open(contactSalesLink, '_blank');
if (isCloud && customer) {
customerEmail = customer.email || '';
firstName = customer.contact_first_name || '';
lastName = customer.contact_last_name || '';
companyName = customer.name || '';
utmMedium = 'in-product-cloud';
} else {
customerEmail = currentUser?.email || '';
}
const contactSalesLink = buildMMURL(LicenseLinks.CONTACT_SALES, firstName, lastName, companyName, customerEmail, utmSource, utmMedium);
const goToSalesLinkFunc = () => {
goToMattermostContactSalesForm(firstName, lastName, companyName, customerEmail, utmSource, utmMedium);
};
return [goToSalesLinkFunc, contactSalesLink];
}

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

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useSelector} from 'react-redux';
import {getCloudCustomer} from 'mattermost-redux/selectors/entities/cloud';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {getCloudSupportLink, getSelfHostedSupportLink, goToCloudSupportForm, goToSelfHostedSupportForm} from 'utils/contact_support_sales';
export function useOpenCloudZendeskSupportForm(subject: string, description: string): [() => void, string] {
const customer = useSelector(getCloudCustomer);
const customerEmail = customer?.email || '';
const url = getCloudSupportLink(customerEmail, subject, description, window.location.host);
return [() => goToCloudSupportForm(customerEmail, subject, description, window.location.host), url];
}
export function useOpenSelfHostedZendeskSupportForm(subject: string): [() => void, string] {
const currentUser = useSelector(getCurrentUser);
const customerEmail = currentUser.email || '';
const url = getSelfHostedSupportLink(customerEmail, subject);
return [() => goToSelfHostedSupportForm(customerEmail, subject), url];
}

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

@@ -1,39 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {SVGProps} from 'react';
const SvgComponent = (props: SVGProps<SVGSVGElement>) => (
<svg
width={140}
height={141}
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
opacity={0.4}
d='M37.593 38.008c4.754-4.815 10.754-7.295 17.989-7.428 7.101.133 13.065 2.601 17.892 7.428 4.815 4.827 7.295 10.791 7.428 17.892-.133 7.235-2.601 13.223-7.428 17.99-4.827 4.754-10.791 7.27-17.892 7.512-7.235-.254-13.223-2.758-17.99-7.513-4.754-4.766-7.258-10.766-7.512-18 .254-7.102 2.758-13.066 7.513-17.881Z'
fill='#fff'
/>
<path
d='M78.887 51.382c-2.151-6.992-6.225-12.225-12.226-15.69-6.001-3.465-12.57-4.376-19.701-2.743-3.9.995-7.297 2.717-10.22 5.162 3.269-3.567 7.415-6.037 12.428-7.416 7.13-1.633 13.732-.703 19.787 2.793s10.161 8.748 12.313 15.74c1.323 5.037 1.257 9.862-.21 14.47-1.454 4.614-4.066 8.49-7.84 11.611 2.833-3.087 4.783-6.713 5.844-10.894 1.05-4.187.991-8.522-.175-13.033Z'
fill='#000'
fillOpacity={0.4}
/>
<path
d='M86.76 53.929c-.508-7.506-3.553-14.097-9.125-19.774-6.346-6.05-13.67-9.08-21.974-9.08-8.303 0-15.616 3.03-21.961 9.08-6.08 6.315-9.126 13.591-9.126 21.855 0 8.262 3.046 15.551 9.126 21.854 5.826 5.556 12.485 8.551 19.967 8.984 7.481.445 14.383-1.611 20.728-6.146l4.75 4.727 6.08-6.05-4.75-4.727c4.69-6.302 6.78-13.218 6.285-20.723Zm-13.126 19.87c-4.823 4.726-10.781 7.228-17.876 7.468-7.228-.252-13.21-2.742-17.973-7.469-4.75-4.727-7.252-10.692-7.506-17.885.254-7.06 2.756-12.99 7.506-17.789 4.75-4.787 10.745-7.252 17.973-7.385 7.095.133 13.053 2.586 17.876 7.385 4.81 4.8 7.288 10.73 7.421 17.79-.133 7.192-2.599 13.157-7.421 17.884Z'
fill='#BABEC9'
/>
<path
d='M106.202 114.187c-1.567.449-2.728.291-3.482-.472L78.06 86.651c-.753-.762-1.064-1.743-.945-2.954.12-1.211.874-2.567 2.262-4.093 1.507-1.393 2.847-2.192 4.044-2.385 1.196-.194 2.165.157 2.92 1.053l26.921 24.957c.753.763.873 1.901.37 3.427-.502 1.525-1.447 3.051-2.823 4.577-1.496 1.526-3.039 2.506-4.607 2.954Z'
fill='#FFBC1F'
/>
<path
d='m108.007 98.343-10.08 10.164-12.154-13.34 8.914-9.106 13.32 12.282Z'
fill='#7A5600'
/>
</svg>
);
export default SvgComponent;

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

@@ -301,6 +301,7 @@ export default function OpenPluginInstallPost(props: {post: Post}) {
className='color--link'
modalId={ModalIdentifiers.PLUGIN_MARKETPLACE}
dialogType={MarketplaceModal}
dialogProps={{openedFrom: 'open_plugin_install_post'}}
>
{text}
</ToggleModalButton>

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

@@ -1,5 +1,7 @@
$dropdown_input_index: 999999;
.DropdownInput {
z-index: 999999;
z-index: $dropdown_input_index;
&.Input_container {
margin-top: 20px;
@@ -37,7 +39,7 @@
}
.DropdownInput__option > div {
z-index: 999999;
z-index: $dropdown_input_index;
padding: 10px 24px;
cursor: pointer;
line-height: 16px;
@@ -51,3 +53,33 @@
.DropdownInput__option.focused > div {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
.second-dropdown-sibling-wrapper {
.DropdownInput {
z-index: $dropdown_input_index - 1;
}
.DropdownInput__option > div {
z-index: $dropdown_input_index - 1;
}
}
.third-dropdown-sibling-wrapper {
.DropdownInput {
z-index: $dropdown_input_index - 2;
}
.DropdownInput__option > div {
z-index: $dropdown_input_index - 2;
}
}
.fourth-dropdown-sibling-wrapper {
.DropdownInput {
z-index: $dropdown_input_index - 3;
}
.DropdownInput__option > div {
z-index: $dropdown_input_index - 3;
}
}

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

@@ -133,7 +133,6 @@
}
.GenericModal__header {
width: 85%;
padding: 0;
border-top-left-radius: 12px;
border-top-right-radius: 12px;

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

@@ -147,6 +147,11 @@ exports[`components/global/product_switcher_menu should match snapshot with id 1
teamId=""
>
<MenuItemToggleModalRedux
dialogProps={
Object {
"openedFrom": "product_menu",
}
}
dialogType={
Object {
"$$typeof": Symbol(react.memo),
@@ -157,14 +162,14 @@ exports[`components/global/product_switcher_menu should match snapshot with id 1
}
icon={
<Icon
glyph="apps"
glyph="view-grid-plus-outline"
size={16}
/>
}
id="marketplaceModal"
modalId="plugin_marketplace"
show={false}
text="Marketplace"
text="App Marketplace"
/>
</Connect(TeamPermissionGate)>
<MenuItemExternalLink
@@ -350,6 +355,11 @@ exports[`components/global/product_switcher_menu should match snapshot with most
teamId=""
>
<MenuItemToggleModalRedux
dialogProps={
Object {
"openedFrom": "product_menu",
}
}
dialogType={
Object {
"$$typeof": Symbol(react.memo),
@@ -360,14 +370,14 @@ exports[`components/global/product_switcher_menu should match snapshot with most
}
icon={
<Icon
glyph="apps"
glyph="view-grid-plus-outline"
size={16}
/>
}
id="marketplaceModal"
modalId="plugin_marketplace"
show={true}
text="Marketplace"
text="App Marketplace"
/>
</Connect(TeamPermissionGate)>
<MenuItemExternalLink
@@ -673,6 +683,11 @@ exports[`components/global/product_switcher_menu should show integrations should
teamId=""
>
<MenuItemToggleModalRedux
dialogProps={
Object {
"openedFrom": "product_menu",
}
}
dialogType={
Object {
"$$typeof": Symbol(react.memo),
@@ -683,14 +698,14 @@ exports[`components/global/product_switcher_menu should show integrations should
}
icon={
<Icon
glyph="apps"
glyph="view-grid-plus-outline"
size={16}
/>
}
id="marketplaceModal"
modalId="plugin_marketplace"
show={false}
text="Marketplace"
text="App Marketplace"
/>
</Connect(TeamPermissionGate)>
<MenuItemExternalLink

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

@@ -213,11 +213,12 @@ const ProductMenuList = (props: Props): JSX.Element | null => {
modalId={ModalIdentifiers.PLUGIN_MARKETPLACE}
show={isMessaging && !isMobile && enablePluginMarketplace}
dialogType={MarketplaceModal}
text={formatMessage({id: 'navbar_dropdown.marketplace', defaultMessage: 'Marketplace'})}
dialogProps={{openedFrom: 'product_menu'}}
text={formatMessage({id: 'navbar_dropdown.marketplace', defaultMessage: 'App Marketplace'})}
icon={
<Icon
size={16}
glyph={'apps'}
glyph='view-grid-plus-outline'
/>
}
/>

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

@@ -1,15 +1,53 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/MoreChannels should match snapshot and state 1`] = `
<GenericModal
<Modal
animation={true}
aria-labelledby="moreChannelsModalLabel"
aria-modal={true}
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={false}
compassDesign={true}
enforceFocus={false}
headerButton={
<Memo(Connect(TeamPermissionGate))
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal more-modal more-modal--action"
dialogComponentClass={[Function]}
enforceFocus={true}
id="moreChannelsModal"
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[Function]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="dialog"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
id="moreChannelsModalHeader"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="moreChannelsModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="More Channels"
id="more_channels.title"
/>
</ModalTitle>
<Connect(TeamPermissionGate)
permissions={
Array [
"create_public_channel",
@@ -18,30 +56,74 @@ exports[`components/MoreChannels should match snapshot and state 1`] = `
teamId="team_id"
>
<button
aria-label="Create New Channel"
className="btn outlineButton"
id="createNewChannelButton"
className="btn btn-primary channel-create-btn"
id="createNewChannel"
onClick={[Function]}
type="button"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Create New Channel"
<MemoizedFormattedMessage
defaultMessage="Create Channel"
id="more_channels.create"
/>
</button>
</Memo(Connect(TeamPermissionGate))>
}
id="moreChannelsModal"
keyboardEscape={true}
modalHeaderText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Browse Channels"
id="more_channels.title"
</Connect(TeamPermissionGate)>
</ModalHeader>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<SearchableChannelList
canShowArchivedChannels={true}
channels={
Array [
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "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,
},
]
}
channelsPerPage={50}
handleJoin={[Function]}
isSearch={false}
loading={false}
nextPage={[Function]}
noResultsText={
<Memo(Connect(TeamPermissionGate))
permissions={
Array [
"create_public_channel",
"create_private_channel",
]
}
teamId="team_id"
>
<p
className="secondary-message"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Click 'Create New Channel' to make a new one"
id="more_channels.createClick"
/>
</p>
</Memo(Connect(TeamPermissionGate))>
}
search={[Function]}
shouldShowArchivedChannels={false}
toggleArchivedChannels={[Function]}
/>
}
onExited={[Function]}
show={true}
>
<LoadingScreen />
</GenericModal>
</ModalBody>
</Modal>
`;

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

@@ -1,82 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/SearchableChannelList should match init snapshot 1`] = `
<div
className="filtered-user-list"
>
<div
className="filter-row filter-row--full"
>
<span
aria-hidden="true"
id="searchIcon"
>
<MagnifyIcon
size={18}
/>
</span>
<QuickInput
aria-label="Search Channels"
className="form-control filter-textbox"
clearable={true}
id="searchChannelsTextbox"
inputComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"render": [Function],
}
}
onClear={[Function]}
onInput={[Function]}
placeholder={
Object {
"defaultMessage": "Search channels",
"id": "filtered_channels_list.search",
}
}
value=""
/>
</div>
<div
className="more-modal__dropdown"
>
<span
id="channelCountLabel"
>
0 Results
</span>
<div
id="modalPreferenceContainer"
>
<div
id="hideJoinedPreferenceCheckbox"
onClick={[Function]}
>
<button
aria-label="Hide joined channels checkbox, not checked"
className="get-app__checkbox"
/>
<MemoizedFormattedMessage
defaultMessage="Hide Joined"
id="more_channels.hide_joined"
/>
</div>
</div>
</div>
<div
className="more-modal__list"
role="application"
tabIndex={-1}
>
<div
id="moreChannelsList"
tabIndex={-1}
>
<LoadingScreen />
</div>
</div>
<div
className="filter-controls"
/>
</div>
`;

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

@@ -12,29 +12,24 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {Action, ActionResult} from 'mattermost-redux/types/actions';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getChannels, getArchivedChannels, joinChannel, getChannelStats} from 'mattermost-redux/actions/channels';
import {getChannelsInCurrentTeam, getMyChannelMemberships, getAllChannelStats} from 'mattermost-redux/selectors/entities/channels';
import {Constants, StoragePrefixes} from 'utils/constants';
import {getChannels, getArchivedChannels, joinChannel} from 'mattermost-redux/actions/channels';
import {getOtherChannels, getChannelsInCurrentTeam} from 'mattermost-redux/selectors/entities/channels';
import {searchMoreChannels} from 'actions/channel_actions';
import {openModal, closeModal} from 'actions/views/modals';
import {setGlobalItem} from 'actions/storage';
import {closeRightHandSide} from 'actions/views/rhs';
import {getIsRhsOpen, getRhsState} from 'selectors/rhs';
import {GlobalState} from 'types/store';
import {ModalData} from 'types/actions';
import {makeGetGlobalItem} from 'selectors/storage';
import {GlobalState} from 'types/store';
import MoreChannels from './more_channels';
const getChannelsWithoutArchived = createSelector(
'getChannelsWithoutArchived',
getChannelsInCurrentTeam,
(channels: Channel[]) => channels && channels.filter((c) => c.delete_at === 0 && c.type !== Constants.PRIVATE_CHANNEL),
const getNotArchivedOtherChannels = createSelector(
'getNotArchivedOtherChannels',
getOtherChannels,
(channels: Channel[]) => channels && channels.filter((c) => c.delete_at === 0),
);
const getArchivedOtherChannels = createSelector(
@@ -45,19 +40,15 @@ const getArchivedOtherChannels = createSelector(
function mapStateToProps(state: GlobalState) {
const team = getCurrentTeam(state) || {};
const getGlobalItem = makeGetGlobalItem(StoragePrefixes.HIDE_JOINED_CHANNELS, 'false');
return {
channels: getChannelsWithoutArchived(state) || [],
channels: getNotArchivedOtherChannels(state) || [],
archivedChannels: getArchivedOtherChannels(state) || [],
currentUserId: getCurrentUserId(state),
teamId: team.id,
teamName: team.name,
channelsRequestStarted: state.requests.channels.getChannels.status === RequestStatus.STARTED,
canShowArchivedChannels: (getConfig(state).ExperimentalViewArchivedChannels === 'true'),
myChannelMemberships: getMyChannelMemberships(state) || {},
allChannelStats: getAllChannelStats(state) || {},
shouldHideJoinedChannels: getGlobalItem(state) === 'true',
rhsState: getRhsState(state),
rhsOpen: getIsRhsOpen(state),
};
@@ -70,8 +61,6 @@ type Actions = {
searchMoreChannels: (term: string, shouldShowArchivedChannels: boolean) => Promise<ActionResult>;
openModal: <P>(modalData: ModalData<P>) => void;
closeModal: (modalId: string) => void;
getChannelStats: (channelId: string) => void;
setGlobalItem: (name: string, value: string) => void;
closeRightHandSide: () => void;
}
@@ -84,8 +73,6 @@ function mapDispatchToProps(dispatch: Dispatch) {
searchMoreChannels,
openModal,
closeModal,
getChannelStats,
setGlobalItem,
closeRightHandSide,
}, dispatch),
};

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

@@ -1,295 +0,0 @@
@charset 'UTF-8';
#moreChannelsModal {
.modal-content {
min-height: 600px;
max-height: calc(50vh - 240px);
}
.modal-dialog {
margin-top: calc(45vh - 240px) !important;
}
.filter-row--full {
position: relative;
margin: 0 32px;
.input-clear {
top: 16px;
right: 16px;
}
#searchIcon {
position: absolute;
top: 14px;
left: 16px;
color: rgba(var(--center-channel-color-rgb), 0.64);
pointer-events: none;
}
#searchChannelsTextbox {
height: 48px;
padding-left: 40px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
box-shadow: none;
font-size: 16px;
&::placeholder {
color: var(--center-channel-color);
}
&:focus {
border: 2px solid var(--button-bg);
}
}
}
.more-modal__dropdown {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 32px;
border-bottom: solid 1px rgba(var(--center-channel-color-rgb), 0.16);
margin: 0;
span {
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
line-height: 16px;
}
.MenuItem__primary-text {
width: 100%;
color: var(--center-channel-color);
font-size: 14px;
font-weight: 400;
line-height: 20px;
svg {
margin-left: auto;
}
}
.Menu__content {
border-color: rgba(var(--center-channel-color-rgb), 0.16);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
#channelCountLabel {
color: var(--center-channel-color);
font-size: 12px;
font-weight: 400;
}
#modalPreferenceContainer {
display: flex;
align-items: center;
justify-content: center;
.get-app__checkbox {
display: flex;
width: 16px;
height: 16px;
align-items: center;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.24);
}
#hideJoinedPreferenceCheckbox {
display: flex;
align-items: center;
cursor: pointer;
}
#channelsMoreDropdown {
margin: 0 8px;
}
#menuWrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 6px 4px 8px;
}
.MenuWrapper:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
border-radius: 4px;
}
.MenuWrapper--open {
background-color: rgba(var(--button-bg-rgb), 0.12);
border-radius: 4px;
&:hover {
background-color: rgba(var(--button-bg-rgb), 0.12);
}
}
}
}
.modal-body {
padding: 15px 0 0;
.filtered-user-list {
height: 500px;
}
.more-modal__row {
padding: 0 32px;
border-bottom: none;
.more-modal__details {
padding-left: 0;
color: rgba(var(--center-channel-color-rgb), 0.56);
svg {
flex-shrink: 0;
}
.more-modal__name {
align-items: center;
margin-top: 0;
span {
color: var(--center-channel-color);
font-weight: 500;
}
}
#channelPurposeContainer {
display: flex;
align-items: center;
justify-content: flex-start;
.dot {
width: 3px;
height: 3px;
flex-shrink: 0;
background-color: rgba(var(--center-channel-color-rgb), 0.56);
border-radius: 50%;
}
.more-modal__description {
margin-left: 4px;
font-weight: 400;
}
#membershipIndicatorContainer {
display: flex;
align-items: center;
span,
svg {
color: var(--online-indicator);
}
}
span {
margin: 0 4px;
font-size: 12px;
font-weight: 600;
line-height: 12px;
opacity: 1;
}
}
}
.more-modal__actions {
button {
display: none;
min-width: 54px;
height: 32px;
font-size: 12px;
font-weight: 600;
}
}
}
.more-modal__row:hover,
.more-modal__row:focus {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
cursor: pointer;
.more-modal__actions {
.primaryButton,
.outlineButton {
display: inline-block;
}
}
}
.form-group {
padding: 0 32px;
margin-bottom: 0;
}
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: none;
}
}
.modal-header {
.GenericModal__header {
display: flex;
width: 95%;
align-items: center;
justify-content: space-between;
padding-right: 4px;
}
.close {
top: 22px;
}
}
.outlineButton {
border: 1px solid var(--button-bg);
background: none;
border-radius: 4px;
color: var(--button-bg);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.outlineButton:hover {
background-color: rgba(var(--button-bg-rgb), 0.08);
}
.filter-controls {
padding: 0;
button {
min-width: 72px;
margin: 8px 32px;
}
}
}
#moreChannelsList {
.primary-message {
margin-top: 8px;
color: var(--center-channel-color);
line-height: 28px;
}
.secondary-message {
margin-bottom: 30px;
}
.primaryButton {
background-color: var(--button-bg);
border-radius: 4px;
color: var(--button-color);
font-size: 14px;
font-weight: 600;
}
#createNewChannelButton {
padding: 10px 20px;
}
}

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

@@ -7,7 +7,7 @@ import {shallow} from 'enzyme';
import {ActionResult} from 'mattermost-redux/types/actions';
import MoreChannels, {Props} from 'components/more_channels/more_channels';
import SearchableChannelList from 'components/more_channels/searchable_channel_list.jsx';
import SearchableChannelList from 'components/searchable_channel_list.jsx';
import {getHistory} from 'utils/browser_history';
import {TestHelper} from 'utils/test_helper';
@@ -59,16 +59,7 @@ describe('components/MoreChannels', () => {
};
const baseProps: Props = {
channels: [
TestHelper.getChannelMock({
id: 'channel-1',
name: 'channel-1',
}),
TestHelper.getChannelMock({
id: 'channel-2',
name: 'channel-2',
}),
],
channels: [TestHelper.getChannelMock({})],
archivedChannels: [TestHelper.getChannelMock({
id: 'channel_id_2',
team_id: 'channel_team_2',
@@ -82,14 +73,6 @@ describe('components/MoreChannels', () => {
teamName: 'team_name',
channelsRequestStarted: false,
canShowArchivedChannels: true,
myChannelMemberships: {
'channel-2': TestHelper.getChannelMembershipMock({
channel_id: 'channel-2',
user_id: 'user-1',
}),
},
allChannelStats: {},
shouldHideJoinedChannels: false,
actions: {
getChannels: jest.fn(),
getArchivedChannels: jest.fn(),
@@ -97,8 +80,6 @@ describe('components/MoreChannels', () => {
searchMoreChannels: jest.fn(channelActions.searchMoreChannels),
openModal: jest.fn(),
closeModal: jest.fn(),
getChannelStats: jest.fn(),
setGlobalItem: jest.fn(),
closeRightHandSide: jest.fn(),
},
};
@@ -110,6 +91,7 @@ describe('components/MoreChannels', () => {
expect(wrapper).toMatchSnapshot();
expect(wrapper.state('searchedChannels')).toEqual([]);
expect(wrapper.state('show')).toEqual(true);
expect(wrapper.state('shouldShowArchivedChannels')).toEqual(false);
expect(wrapper.state('search')).toEqual(false);
expect(wrapper.state('serverError')).toBeNull();
@@ -120,6 +102,16 @@ describe('components/MoreChannels', () => {
expect(wrapper.instance().props.actions.getChannels).toHaveBeenCalledWith(wrapper.instance().props.teamId, 0, 100);
});
test('should match state on handleHide', () => {
const wrapper = shallow<MoreChannels>(
<MoreChannels {...baseProps}/>,
);
wrapper.setState({show: true});
wrapper.instance().handleHide();
expect(wrapper.state('show')).toEqual(false);
});
test('should call closeModal on handleExit', () => {
const wrapper = shallow<MoreChannels>(
<MoreChannels {...baseProps}/>,
@@ -160,7 +152,7 @@ describe('components/MoreChannels', () => {
<MoreChannels {...baseProps}/>,
);
wrapper.setState({loading: false, search: true, searching: true});
wrapper.setState({search: true, searching: true});
const searchList = wrapper.find(SearchableChannelList);
expect(searchList.props().loading).toEqual(true);
});
@@ -219,6 +211,7 @@ describe('components/MoreChannels', () => {
process.nextTick(() => {
expect(getHistory().push).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(1);
expect(wrapper.state('show')).toEqual(false);
done();
});
});
@@ -256,7 +249,7 @@ describe('components/MoreChannels', () => {
jest.runOnlyPendingTimers();
expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledTimes(1);
expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledWith('fail', false, false);
expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledWith('fail', false);
process.nextTick(() => {
expect(wrapper.state('search')).toEqual(true);
expect(wrapper.state('searching')).toEqual(false);
@@ -283,7 +276,7 @@ describe('components/MoreChannels', () => {
jest.runOnlyPendingTimers();
expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledTimes(1);
expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledWith('channel', false, false);
expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledWith('channel', false);
process.nextTick(() => {
expect(wrapper.state('search')).toEqual(true);
expect(wrapper.state('searching')).toEqual(false);
@@ -310,7 +303,7 @@ describe('components/MoreChannels', () => {
jest.runOnlyPendingTimers();
expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledTimes(1);
expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledWith('channel', true, false);
expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledWith('channel', true);
process.nextTick(() => {
expect(wrapper.state('search')).toEqual(true);
expect(wrapper.state('searching')).toEqual(false);
@@ -318,16 +311,4 @@ describe('components/MoreChannels', () => {
done();
});
});
test('should hide joined channels from channels props when shouldHideJoinedChannels prop is true', () => {
const props = {
...baseProps,
shouldHideJoinedChannels: true,
};
const wrapper = shallow<MoreChannels>(
<MoreChannels {...props}/>,
);
expect(wrapper.instance().activeChannels).not.toContain(baseProps.channels[1]);
});
});

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

@@ -2,32 +2,23 @@
// See LICENSE.txt for license information.
import React from 'react';
import {Modal} from 'react-bootstrap';
import {FormattedMessage} from 'react-intl';
import classNames from 'classnames';
import {ActionResult} from 'mattermost-redux/types/actions';
import {Channel, ChannelMembership, ChannelStats} from '@mattermost/types/channels';
import {Channel} from '@mattermost/types/channels';
import Permissions from 'mattermost-redux/constants/permissions';
import {RelationOneToOne} from '@mattermost/types/utilities';
import NewChannelModal from 'components/new_channel_modal/new_channel_modal';
import SearchableChannelList from 'components/searchable_channel_list.jsx';
import TeamPermissionGate from 'components/permissions_gates/team_permission_gate';
import GenericModal from 'components/generic_modal';
import LoadingScreen from 'components/loading_screen';
import {ModalData} from 'types/actions';
import {RhsState} from 'types/store/rhs';
import {getHistory} from 'utils/browser_history';
import {ModalIdentifiers, StoragePrefixes, RHSStates} from 'utils/constants';
import {ModalIdentifiers, RHSStates} from 'utils/constants';
import {getRelativeChannelURL} from 'utils/url';
import {localizeMessage} from 'utils/utils';
import SearchableChannelList from './searchable_channel_list';
import './more_channels.scss';
const CHANNELS_CHUNK_SIZE = 50;
const CHANNELS_PER_PAGE = 50;
@@ -37,15 +28,9 @@ type Actions = {
getChannels: (teamId: string, page: number, perPage: number) => void;
getArchivedChannels: (teamId: string, page: number, channelsPerPage: number) => void;
joinChannel: (currentUserId: string, teamId: string, channelId: string) => Promise<ActionResult>;
searchMoreChannels: (term: string, shouldShowArchivedChannels: boolean, shouldHideJoinedChannels: boolean) => Promise<ActionResult>;
searchMoreChannels: (term: string, shouldShowArchivedChannels: boolean) => Promise<ActionResult>;
openModal: <P>(modalData: ModalData<P>) => void;
closeModal: (modalId: string) => void;
getChannelStats: (channelId: string) => void;
/*
* Function to set a key-value pair in the local storage
*/
setGlobalItem: (name: string, value: string) => void;
closeRightHandSide: () => void;
}
@@ -58,27 +43,23 @@ export type Props = {
channelsRequestStarted?: boolean;
canShowArchivedChannels?: boolean;
morePublicChannelsModalType?: string;
myChannelMemberships: RelationOneToOne<Channel, ChannelMembership>;
allChannelStats: RelationOneToOne<Channel, ChannelStats>;
shouldHideJoinedChannels: boolean;
rhsState?: RhsState;
rhsOpen?: boolean;
actions: Actions;
}
type State = {
show: boolean;
shouldShowArchivedChannels: boolean;
search: boolean;
searchedChannels: Channel[];
serverError: React.ReactNode | string;
searching: boolean;
searchTerm: string;
loading: boolean;
}
export default class MoreChannels extends React.PureComponent<Props, State> {
public searchTimeoutId: number;
activeChannels: Channel[] = [];
constructor(props: Props) {
super(props);
@@ -86,27 +67,25 @@ export default class MoreChannels extends React.PureComponent<Props, State> {
this.searchTimeoutId = 0;
this.state = {
show: true,
shouldShowArchivedChannels: this.props.morePublicChannelsModalType === 'private',
search: false,
searchedChannels: [],
serverError: null,
searching: false,
searchTerm: '',
loading: true,
};
}
async componentDidMount() {
await this.props.actions.getChannels(this.props.teamId, 0, CHANNELS_CHUNK_SIZE * 2);
componentDidMount() {
this.props.actions.getChannels(this.props.teamId, 0, CHANNELS_CHUNK_SIZE * 2);
if (this.props.canShowArchivedChannels) {
await this.props.actions.getArchivedChannels(this.props.teamId, 0, CHANNELS_CHUNK_SIZE * 2);
this.props.actions.getArchivedChannels(this.props.teamId, 0, CHANNELS_CHUNK_SIZE * 2);
}
await this.props.channels.forEach((channel) => this.props.actions.getChannelStats(channel.id));
this.loadComplete();
}
loadComplete = () => {
this.setState({loading: false});
handleHide = () => {
this.setState({show: false});
}
handleNewChannel = () => {
@@ -145,17 +124,14 @@ export default class MoreChannels extends React.PureComponent<Props, State> {
handleJoin = async (channel: Channel, done: () => void) => {
const {actions, currentUserId, teamId, teamName} = this.props;
let result;
const result = await actions.joinChannel(currentUserId, teamId, channel.id);
if (!this.isMemberOfChannel(channel.id)) {
result = await actions.joinChannel(currentUserId, teamId, channel.id);
}
if (result?.error) {
if (result.error) {
this.setState({serverError: result.error.message});
} else {
getHistory().push(getRelativeChannelURL(teamName, channel.name));
this.closeEditRHS();
this.handleHide();
}
if (done) {
@@ -177,7 +153,7 @@ export default class MoreChannels extends React.PureComponent<Props, State> {
const searchTimeoutId = window.setTimeout(
async () => {
try {
const {data} = await this.props.actions.searchMoreChannels(term, this.state.shouldShowArchivedChannels, this.props.shouldHideJoinedChannels);
const {data} = await this.props.actions.searchMoreChannels(term, this.state.shouldShowArchivedChannels);
if (searchTimeoutId !== this.searchTimeoutId) {
return;
}
@@ -207,47 +183,29 @@ export default class MoreChannels extends React.PureComponent<Props, State> {
this.setState({shouldShowArchivedChannels});
}
isMemberOfChannel(channelId: string) {
return this.props.myChannelMemberships[channelId];
}
handleShowJoinedChannelsPreference = (shouldHideJoinedChannels: boolean) => {
// search again when switching channels to update search results
this.search(this.state.searchTerm);
this.props.actions.setGlobalItem(StoragePrefixes.HIDE_JOINED_CHANNELS, shouldHideJoinedChannels.toString());
}
otherChannelsWithoutJoined = this.props.channels.filter((channel) => !this.isMemberOfChannel(channel.id));
archivedChannelsWithoutJoined = this.props.archivedChannels.filter((channel) => !this.isMemberOfChannel(channel.id));
render() {
const {
channels,
archivedChannels,
teamId,
channelsRequestStarted,
shouldHideJoinedChannels,
} = this.props;
const {
search,
searchedChannels,
serverError: serverErrorState,
show,
searching,
shouldShowArchivedChannels,
} = this.state;
const otherChannelsWithoutJoined = channels.filter((channel) => !this.isMemberOfChannel(channel.id));
const archivedChannelsWithoutJoined = archivedChannels.filter((channel) => !this.isMemberOfChannel(channel.id));
let activeChannels;
if (shouldShowArchivedChannels && shouldHideJoinedChannels) {
this.activeChannels = search ? searchedChannels : archivedChannelsWithoutJoined;
} else if (shouldShowArchivedChannels && !shouldHideJoinedChannels) {
this.activeChannels = search ? searchedChannels : archivedChannels;
} else if (!shouldShowArchivedChannels && shouldHideJoinedChannels) {
this.activeChannels = search ? searchedChannels : otherChannelsWithoutJoined;
if (shouldShowArchivedChannels) {
activeChannels = search ? searchedChannels : archivedChannels;
} else {
this.activeChannels = search ? searchedChannels : channels;
activeChannels = search ? searchedChannels : channels;
}
let serverError;
@@ -256,87 +214,87 @@ export default class MoreChannels extends React.PureComponent<Props, State> {
<div className='form-group has-error'><label className='control-label'>{serverErrorState}</label></div>;
}
const createNewChannelButton = (className: string, icon?: JSX.Element) => {
const buttonClassName = classNames('btn', className);
return (
<TeamPermissionGate
teamId={teamId}
permissions={[Permissions.CREATE_PUBLIC_CHANNEL]}
const createNewChannelButton = (
<TeamPermissionGate
teamId={teamId}
permissions={[Permissions.CREATE_PUBLIC_CHANNEL]}
>
<button
id='createNewChannel'
type='button'
className='btn btn-primary channel-create-btn'
onClick={this.handleNewChannel}
>
<button
type='button'
id='createNewChannelButton'
className={buttonClassName}
onClick={this.handleNewChannel}
aria-label={localizeMessage('more_channels.create', 'Create New Channel')}
>
{icon}
<FormattedMessage
id='more_channels.create'
defaultMessage='Create New Channel'
/>
</button>
</TeamPermissionGate>
);
};
const noResultsText = (
<>
<p className='secondary-message'>
<FormattedMessage
id='more_channels.searchError'
defaultMessage='Try searching different keywords, checking for typos or adjusting the filters.'
id='more_channels.create'
defaultMessage='Create Channel'
/>
</p>
{createNewChannelButton('primaryButton', <i className='icon-plus'/>)}
</>
</button>
</TeamPermissionGate>
);
const body = this.state.loading ? <LoadingScreen/> : (
const createChannelHelpText = (
<TeamPermissionGate
teamId={teamId}
permissions={[Permissions.CREATE_PUBLIC_CHANNEL, Permissions.CREATE_PRIVATE_CHANNEL]}
>
<p className='secondary-message'>
<FormattedMessage
id='more_channels.createClick'
defaultMessage="Click 'Create New Channel' to make a new one"
/>
</p>
</TeamPermissionGate>
);
const body = (
<React.Fragment>
<SearchableChannelList
channels={this.activeChannels}
channels={activeChannels}
channelsPerPage={CHANNELS_PER_PAGE}
nextPage={this.nextPage}
isSearch={search}
search={this.search}
handleJoin={this.handleJoin}
noResultsText={noResultsText}
noResultsText={createChannelHelpText}
loading={search ? searching : channelsRequestStarted}
toggleArchivedChannels={this.toggleArchivedChannels}
shouldShowArchivedChannels={this.state.shouldShowArchivedChannels}
canShowArchivedChannels={this.props.canShowArchivedChannels}
myChannelMemberships={this.props.myChannelMemberships}
allChannelStats={this.props.allChannelStats}
closeModal={this.props.actions.closeModal}
hideJoinedChannelsPreference={this.handleShowJoinedChannelsPreference}
rememberHideJoinedChannelsChecked={shouldHideJoinedChannels}
/>
{serverError}
</React.Fragment>
);
const title = (
<FormattedMessage
id='more_channels.title'
defaultMessage='Browse Channels'
/>
);
return (
<GenericModal
<Modal
dialogClassName='a11y__modal more-modal more-modal--action'
show={show}
onHide={this.handleHide}
onExited={this.handleExit}
compassDesign={true}
role='dialog'
id='moreChannelsModal'
aria-labelledby='moreChannelsModalLabel'
modalHeaderText={title}
headerButton={createNewChannelButton('outlineButton')}
autoCloseOnConfirmButton={false}
aria-modal={true}
enforceFocus={false}
>
{body}
</GenericModal>
<Modal.Header
id='moreChannelsModalHeader'
closeButton={true}
>
<Modal.Title
componentClass='h1'
id='moreChannelsModalLabel'
>
<FormattedMessage
id='more_channels.title'
defaultMessage='More Channels'
/>
</Modal.Title>
{createNewChannelButton}
</Modal.Header>
<Modal.Body>
{body}
</Modal.Body>
</Modal>
);
}
}

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

@@ -1,483 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {AccountOutlineIcon, ArchiveOutlineIcon, CheckIcon, ChevronDownIcon, GlobeIcon, LockOutlineIcon, MagnifyIcon} from '@mattermost/compass-icons/components';
import classNames from 'classnames';
import {isPrivateChannel} from 'mattermost-redux/utils/channel_utils';
import LoadingScreen from 'components/loading_screen';
import LoadingWrapper from 'components/widgets/loading/loading_wrapper';
import QuickInput from 'components/quick_input';
import LocalizedInput from 'components/localized_input/localized_input';
import CheckboxCheckedIcon from 'components/widgets/icons/checkbox_checked_icon';
import MagnifyingGlassSVG from 'components/common/svg_images_components/magnifying_glass_svg';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import Menu from 'components/widgets/menu/menu';
import {t} from 'utils/i18n';
import * as UserAgent from 'utils/user_agent';
import Constants, {ModalIdentifiers} from 'utils/constants';
import {isKeyPressed, localizeMessage, localizeAndFormatMessage} from 'utils/utils';
import {isArchivedChannel} from 'utils/channel_utils';
const NEXT_BUTTON_TIMEOUT_MILLISECONDS = 500;
export default class SearchableChannelList extends React.PureComponent {
static getDerivedStateFromProps(props, state) {
return {isSearch: props.isSearch, page: props.isSearch && !state.isSearch ? 0 : state.page};
}
constructor(props) {
super(props);
this.nextTimeoutId = 0;
this.state = {
joiningChannel: '',
page: 0,
nextDisabled: false,
channelSearchValue: '',
};
this.filter = React.createRef();
this.channelListScroll = React.createRef();
}
componentDidMount() {
// only focus the search box on desktop so that we don't cause the keyboard to open on mobile
if (!UserAgent.isMobile() && this.filter.current) {
this.filter.current.focus();
}
document.addEventListener('keydown', this.onKeyDown);
}
componentWillUnmount() {
document.removeEventListener('keydown', this.onKeyDown);
}
onKeyDown = (e) => {
const target = e.target;
const isEnterKeyPressed = isKeyPressed(e, Constants.KeyCodes.ENTER);
if (isEnterKeyPressed && (e.shiftKey || e.ctrlKey || e.altKey)) {
return;
}
if (isEnterKeyPressed && target.classList.contains('more-modal__row')) {
target.click();
}
}
handleJoin = (channel, e) => {
e.stopPropagation();
this.setState({joiningChannel: channel.id});
this.props.handleJoin(
channel,
() => {
this.setState({joiningChannel: ''});
},
);
if (this.isMemberOfChannel(channel.id)) {
this.props.closeModal(ModalIdentifiers.MORE_CHANNELS);
}
}
isMemberOfChannel(channelId) {
return this.props.myChannelMemberships[channelId];
}
createChannelRow = (channel) => {
const ariaLabel = `${channel.display_name}, ${channel.purpose}`.toLowerCase();
let channelTypeIcon;
let memberCount = 0;
if (this.props.allChannelStats[channel.id]) {
memberCount = this.props.allChannelStats[channel.id].member_count;
}
if (isArchivedChannel(channel)) {
channelTypeIcon = <ArchiveOutlineIcon size={18}/>;
} else if (isPrivateChannel(channel)) {
channelTypeIcon = <LockOutlineIcon size={18}/>;
} else {
channelTypeIcon = <GlobeIcon size={18}/>;
}
const membershipIndicator = this.isMemberOfChannel(channel.id) ? (
<div
id='membershipIndicatorContainer'
aria-label={localizeMessage('more_channels.membership_indicator', 'Membership Indicator: Joined')}
>
<CheckIcon size={14}/>
<FormattedMessage
id={'more_channels.joined'}
defaultMessage={'Joined'}
/>
<span className='dot'/>
</div>
) : null;
const channelPurposeContainerAriaLabel = localizeAndFormatMessage(
t('more_channels.channel_purpose'),
'Channel Information: Membership Indicator: Joined, Member count {memberCount} , Purpose: {channelPurpose}',
{memberCount, channelPurpose: channel.purpose || ''},
);
const channelPurposeContainer = (
<div
id='channelPurposeContainer'
aria-label={channelPurposeContainerAriaLabel}
>
{membershipIndicator}
<AccountOutlineIcon size={14}/>
<span>{memberCount}</span>
{channel.purpose.length > 0 && <span className='dot'/>}
<span className='more-modal__description'>{channel.purpose}</span>
</div>
);
const joinViewChannelButtonClass = classNames('btn', {
outlineButton: this.isMemberOfChannel(channel.id),
primaryButton: !this.isMemberOfChannel(channel.id),
});
const joinViewChannelButton = (
<button
id='joinViewChannelButton'
onClick={(e) => this.handleJoin(channel, e)}
className={joinViewChannelButtonClass}
disabled={this.state.joiningChannel}
tabIndex={-1}
aria-label={this.isMemberOfChannel(channel.id) ? localizeMessage('more_channels.view', 'View') : localizeMessage('joinChannel.JoinButton', 'Join')}
>
<LoadingWrapper
loading={this.state.joiningChannel === channel.id}
text={localizeMessage('joinChannel.joiningButton', 'Joining...')}
>
<FormattedMessage
id={this.isMemberOfChannel(channel.id) ? 'more_channels.view' : 'joinChannel.JoinButton'}
defaultMessage={this.isMemberOfChannel(channel.id) ? 'View' : 'Join'}
/>
</LoadingWrapper>
</button>
);
return (
<div
className='more-modal__row'
key={channel.id}
id={`ChannelRow-${channel.name}`}
aria-label={ariaLabel}
onClick={(e) => this.handleJoin(channel, e)}
tabIndex={0}
>
<div className='more-modal__details'>
<div className='style--none more-modal__name'>
{channelTypeIcon}
<span id='channelName'>{channel.display_name}</span>
</div>
{channelPurposeContainer}
</div>
<div className='more-modal__actions'>
{joinViewChannelButton}
</div>
</div>
);
}
nextPage = (e) => {
e.preventDefault();
this.setState({page: this.state.page + 1, nextDisabled: true});
this.nextTimeoutId = setTimeout(() => this.setState({nextDisabled: false}), NEXT_BUTTON_TIMEOUT_MILLISECONDS);
this.props.nextPage(this.state.page + 1);
this.channelListScroll.current?.scrollTo({top: 0});
}
previousPage = (e) => {
e.preventDefault();
this.setState({page: this.state.page - 1});
this.channelListScroll.current?.scrollTo({top: 0});
}
doSearch = () => {
this.props.search(this.state.channelSearchValue);
if (this.state.channelSearchValue === '') {
this.setState({page: 0});
}
}
handleChange = (e) => {
if (e.target) {
this.setState({channelSearchValue: e.target.value}, () => this.doSearch());
}
}
handleClear = () => {
this.setState({channelSearchValue: ''}, () => this.doSearch());
}
toggleArchivedChannelsOn = () => {
this.props.toggleArchivedChannels(true);
}
toggleArchivedChannelsOff = () => {
this.props.toggleArchivedChannels(false);
}
handleChecked = () => {
// If it was checked, and now we're unchecking it, clear the preference
if (this.props.rememberHideJoinedChannelsChecked) {
this.props.hideJoinedChannelsPreference(false);
} else {
this.props.hideJoinedChannelsPreference(true);
}
}
render() {
const channels = this.props.channels;
let listContent;
let nextButton;
let previousButton;
let emptyStateMessage = (
<FormattedMessage
id={this.props.shouldShowArchivedChannels ? t('more_channels.noArchived') : t('more_channels.noPublic')}
tagName='strong'
defaultMessage={this.props.shouldShowArchivedChannels ? 'No archived channels' : 'No public channels'}
/>
);
if (this.state.channelSearchValue.length > 0) {
emptyStateMessage = (
<FormattedMessage
id='more_channels.noMore'
tagName='strong'
defaultMessage='No results for {text}'
values={{
text: this.state.channelSearchValue,
}}
/>
);
}
if (this.props.loading && channels.length === 0) {
listContent = <LoadingScreen/>;
} else if (channels.length === 0) {
listContent = (
<div
className='no-channel-message'
aria-label={this.state.channelSearchValue.length > 0 ?
localizeAndFormatMessage(t('more_channels.noMore'), 'No results for {text}', {text: this.state.channelSearchValue}) :
localizeMessage('widgets.channels_input.empty', 'No channels found')
}
>
<MagnifyingGlassSVG/>
<h3 className='primary-message'>
{emptyStateMessage}
</h3>
{this.props.noResultsText}
</div>
);
} else {
const pageStart = this.state.page * this.props.channelsPerPage;
const pageEnd = pageStart + this.props.channelsPerPage;
const channelsToDisplay = this.props.channels.slice(pageStart, pageEnd);
listContent = channelsToDisplay.map(this.createChannelRow);
if (channelsToDisplay.length >= this.props.channelsPerPage && pageEnd < this.props.channels.length) {
nextButton = (
<button
className='btn filter-control filter-control__next outlineButton'
onClick={this.nextPage}
disabled={this.state.nextDisabled}
aria-label={localizeMessage('more_channels.next', 'Next')}
>
<FormattedMessage
id='more_channels.next'
defaultMessage='Next'
/>
</button>
);
}
if (this.state.page > 0) {
previousButton = (
<button
className='btn filter-control filter-control__prev outlineButton'
onClick={this.previousPage}
aria-label={localizeMessage('more_channels.prev', 'Previous')}
>
<FormattedMessage
id='more_channels.prev'
defaultMessage='Previous'
/>
</button>
);
}
}
const input = (
<div className='filter-row filter-row--full'>
<span
id='searchIcon'
aria-hidden='true'
>
<MagnifyIcon size={18}/>
</span>
<QuickInput
id='searchChannelsTextbox'
ref={this.filter}
className='form-control filter-textbox'
placeholder={{id: t('filtered_channels_list.search'), defaultMessage: 'Search channels'}}
inputComponent={LocalizedInput}
onInput={this.handleChange}
clearable={true}
onClear={this.handleClear}
value={this.state.channelSearchValue}
aria-label={localizeMessage('filtered_channels_list.search', 'Search Channels')}
/>
</div>
);
let channelDropdown;
let checkIcon;
if (this.props.canShowArchivedChannels) {
checkIcon = (
<CheckIcon
size={18}
color={'var(--button-bg)'}
/>
);
channelDropdown = (
<MenuWrapper id='channelsMoreDropdown'>
<a id='menuWrapper'>
<span>{this.props.shouldShowArchivedChannels ? localizeMessage('more_channels.show_archived_channels', 'Channel Type: Archived') : localizeMessage('more_channels.show_public_channels', 'Channel Type: Public')}</span>
<ChevronDownIcon
color={'rgba(var(--center-channel-color-rgb), 0.64)'}
size={16}
/>
</a>
<Menu
openLeft={false}
ariaLabel={localizeMessage('more_channels.title', 'Browse channels')}
>
<div id='modalPreferenceContainer'>
<Menu.ItemAction
id='channelsMoreDropdownPublic'
onClick={this.toggleArchivedChannelsOff}
icon={<GlobeIcon size={16}/>}
text={localizeMessage('suggestion.search.public', 'Public Channels')}
rightDecorator={this.props.shouldShowArchivedChannels ? null : checkIcon}
ariaLabel={localizeMessage('suggestion.search.public', 'Public Channels')}
/>
</div>
<Menu.ItemAction
id='channelsMoreDropdownArchived'
onClick={this.toggleArchivedChannelsOn}
icon={<ArchiveOutlineIcon size={16}/>}
text={localizeMessage('suggestion.archive', 'Archived Channels')}
rightDecorator={this.props.shouldShowArchivedChannels ? checkIcon : null}
ariaLabel={localizeMessage('suggestion.archive', 'Archived Channels')}
/>
</Menu>
</MenuWrapper>
);
}
const hideJoinedButtonClass = classNames('get-app__checkbox', {checked: this.props.rememberHideJoinedChannelsChecked});
const hideJoinedPreferenceCheckbox = (
<div
id={'hideJoinedPreferenceCheckbox'}
onClick={this.handleChecked}
>
<button
className={hideJoinedButtonClass}
aria-label={this.props.rememberHideJoinedChannelsChecked ? localizeMessage('more_channels.hide_joined_checked', 'Hide joined channels checkbox, checked') : localizeMessage('more_channels.hide_joined_not_checked', 'Hide joined channels checkbox, not checked')
}
>
{this.props.rememberHideJoinedChannelsChecked ? <CheckboxCheckedIcon/> : null}
</button>
<FormattedMessage
id='more_channels.hide_joined'
defaultMessage='Hide Joined'
/>
</div>
);
let channelCountLabel;
if (channels.length === 0) {
channelCountLabel = localizeMessage('more_channels.count_zero', '0 Results');
} else if (channels.length === 1) {
channelCountLabel = localizeMessage('more_channels.count_one', '1 Result');
} else if (channels.length > 1) {
channelCountLabel = localizeAndFormatMessage(t('more_channels.count'), '0 Results', {count: channels.length});
} else {
channelCountLabel = localizeMessage('more_channels.count_zero', '0 Results');
}
const dropDownContainer = (
<div className='more-modal__dropdown'>
<span id='channelCountLabel'>{channelCountLabel}</span>
<div id='modalPreferenceContainer'>
{channelDropdown}
{hideJoinedPreferenceCheckbox}
</div>
</div>
);
return (
<div className='filtered-user-list'>
{input}
{dropDownContainer}
<div
role='application'
className='more-modal__list'
tabIndex={-1}
>
<div
id='moreChannelsList'
tabIndex={-1}
ref={this.channelListScroll}
>
{listContent}
</div>
</div>
<div className='filter-controls'>
{previousButton}
{nextButton}
</div>
</div>
);
}
}
SearchableChannelList.defaultProps = {
channels: [],
isSearch: false,
};
SearchableChannelList.propTypes = {
channels: PropTypes.arrayOf(PropTypes.object),
channelsPerPage: PropTypes.number,
nextPage: PropTypes.func.isRequired,
isSearch: PropTypes.bool,
search: PropTypes.func.isRequired,
handleJoin: PropTypes.func.isRequired,
noResultsText: PropTypes.object,
loading: PropTypes.bool,
toggleArchivedChannels: PropTypes.func.isRequired,
shouldShowArchivedChannels: PropTypes.bool.isRequired,
canShowArchivedChannels: PropTypes.bool.isRequired,
myChannelMemberships: PropTypes.object.isRequired,
allChannelStats: PropTypes.object.isRequired,
closeModal: PropTypes.func.isRequired,
hideJoinedChannelsPreference: PropTypes.func.isRequired,
rememberHideJoinedChannelsChecked: PropTypes.bool.isRequired,
};
/* eslint-enable react/no-string-refs */

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

@@ -61,25 +61,27 @@ const AddressForm = (props: AddressFormProps) => {
{...props.title}
/>
</div>
<DropdownInput
onChange={handleCountryChange}
value={
props.address.country ? {value: props.address.country, label: props.address.country} : undefined
}
options={COUNTRIES.map((country) => ({
value: country.name,
label: country.name,
}))}
legend={formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
placeholder={formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
name={'billing_dropdown'}
/>
<div className='third-dropdown-sibling-wrapper'>
<DropdownInput
onChange={handleCountryChange}
value={
props.address.country ? {value: props.address.country, label: props.address.country} : undefined
}
options={COUNTRIES.map((country) => ({
value: country.name,
label: country.name,
}))}
legend={formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
placeholder={formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
name={'billing_dropdown'}
/>
</div>
<div className='form-row'>
<Input
name='address'
@@ -122,7 +124,7 @@ const AddressForm = (props: AddressFormProps) => {
/>
</div>
<div className='form-row'>
<div className='form-row-third-1 selector'>
<div className='form-row-third-1 selector fourth-dropdown-sibling-wrapper'>
<StateSelector
country={props.address.country}
state={props.address.state}

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

@@ -12,7 +12,6 @@
.form-row-third-1 {
.DropdownInput {
z-index: 99999;
margin-top: 0;
}
@@ -37,7 +36,6 @@
.DropdownInput {
position: relative;
z-index: 999999;
height: 36px;
margin-bottom: 24px;
font-weight: normal;

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

@@ -251,7 +251,7 @@ export default class PaymentForm extends React.PureComponent<Props, State> {
/>
</div>
<div className='form-row'>
<div className='form-row-third-1 selector'>
<div className='form-row-third-1 selector second-dropdown-sibling-wrapper'>
<StateSelector
country={this.state.country}
state={this.state.state}

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

@@ -119,6 +119,7 @@ describe('components/marketplace/', () => {
pluginStatuses: {},
siteURL: 'http://example.com',
firstAdminVisitMarketplaceStatus: false,
openedFrom: 'actions_menu',
actions: {
closeModal: jest.fn(),
fetchListing: jest.fn(() => {
@@ -191,8 +192,21 @@ describe('components/marketplace/', () => {
wrapper.setState({filter: 'nps'});
wrapper.instance().doSearch();
expect(trackEvent).toHaveBeenCalledWith('plugins', 'ui_marketplace_opened');
expect(trackEvent).toHaveBeenCalledWith('plugins', 'ui_marketplace_opened', {from: 'actions_menu'});
expect(trackEvent).toHaveBeenCalledWith('plugins', 'ui_marketplace_search', {filter: 'nps'});
});
test('Should call for opened track event on mount', () => {
const openedFrom = 'actions_menu';
shallow<MarketplaceModal>(
<MarketplaceModal
{...baseProps}
openedFrom={openedFrom}
/>,
);
expect(trackEvent).toHaveBeenCalledWith('plugins', 'ui_marketplace_opened', {from: openedFrom});
});
});
});

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

@@ -31,6 +31,8 @@ const MarketplaceTabs = {
const SEARCH_TIMEOUT_MILLISECONDS = 200;
export type OpenedFromType = 'actions_menu' | 'app_bar' | 'channel_header' | 'command' | 'open_plugin_install_post' | 'product_menu';
type AllListingProps = {
listing: Array<MarketplacePlugin | MarketplaceApp>;
};
@@ -97,6 +99,7 @@ export type MarketplaceModalProps = {
siteURL: string;
pluginStatuses?: Record<string, PluginStatusRedux>;
firstAdminVisitMarketplaceStatus: boolean;
openedFrom: OpenedFromType;
actions: {
closeModal: () => void;
fetchListing(localOnly?: boolean): Promise<{error?: Error}>;
@@ -131,7 +134,7 @@ export default class MarketplaceModal extends React.PureComponent<MarketplaceMod
}
componentDidMount(): void {
trackEvent('plugins', 'ui_marketplace_opened');
trackEvent('plugins', 'ui_marketplace_opened', {from: this.props.openedFrom});
this.fetchListing();
this.props.actions.getPluginStatuses();

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

@@ -155,7 +155,7 @@ function PostPriorityPicker({
}
}
const feedbackLink = postAcknowledgementsEnabled ? 'https://forms.gle/noA8Azg7RdaBZtMB6' : 'https://forms.gle/XRb63s3KZqpLNyqr9';
const feedbackLink = postAcknowledgementsEnabled ? 'https://forms.gle/noA8Azg7RdaBZtMB6' : 'https://forms.gle/mMcRFQzyKAo9Sv49A';
return (
<Picker

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

@@ -11,8 +11,7 @@ import {trackEvent} from 'actions/telemetry_actions';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {LicenseLinks, TELEMETRY_CATEGORIES} from 'utils/constants';
import {SalesInquiryIssue} from 'selectors/cloud';
import {TELEMETRY_CATEGORIES} from 'utils/constants';
const StyledA = styled.a`
color: var(--denim-button-bg);
@@ -27,11 +26,7 @@ text-align: center;
function ContactSalesCTA() {
const {formatMessage} = useIntl();
const openSalesLink = useOpenSalesLink(SalesInquiryIssue.UpgradeEnterprise);
const openSelfHostedLink = () => {
window.open(LicenseLinks.CONTACT_SALES, '_blank');
};
const [openSalesLink] = useOpenSalesLink();
const isCloud = useSelector(isCurrentLicenseCloud);
@@ -42,11 +37,10 @@ function ContactSalesCTA() {
e.preventDefault();
if (isCloud) {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_PRICING, 'click_enterprise_contact_sales');
openSalesLink();
} else {
trackEvent('self_hosted_pricing', 'click_enterprise_contact_sales');
openSelfHostedLink();
}
openSalesLink();
}}
>
{formatMessage({id: 'pricing_modal.btn.contactSalesForQuote', defaultMessage: 'Contact Sales'})}

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

@@ -10,8 +10,6 @@ import {CloudLinks, CloudProducts, LicenseSkus, ModalIdentifiers, MattermostFeat
import {fallbackStarterLimits, asGBString, hasSomeLimits} from 'utils/limits';
import {findOnlyYearlyProducts, findProductBySku} from 'utils/products';
import {getCloudContactUsLink, InquiryType, SalesInquiryIssue} from 'selectors/cloud';
import {trackEvent} from 'actions/telemetry_actions';
import {closeModal, openModal} from 'actions/views/modals';
import {subscribeCloudSubscription} from 'actions/cloud';
@@ -38,6 +36,8 @@ import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurch
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import useOpenDowngradeModal from 'components/common/hooks/useOpenDowngradeModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {useOpenCloudZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import ExternalLink from 'components/external_link';
import DowngradeTeamRemovalModal from './downgrade_team_removal_modal';
@@ -64,15 +64,12 @@ function Content(props: ContentProps) {
const openPricingModalBackAction = useOpenPricingModal();
const isAdmin = useSelector(isCurrentUserSystemAdmin);
const contactSalesLink = useSelector(getCloudContactUsLink)(InquiryType.Sales, SalesInquiryIssue.UpgradeEnterprise);
const subscription = useSelector(selectCloudSubscription);
const currentProduct = useSelector(selectSubscriptionProduct);
const products = useSelector(selectCloudProducts);
const yearlyProducts = findOnlyYearlyProducts(products || {}); // pricing modal should now only show yearly products
const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical);
const currentSubscriptionIsMonthly = currentProduct?.recurring_interval === RecurringIntervals.MONTH;
const isEnterprise = currentProduct?.sku === CloudProducts.ENTERPRISE;
const isEnterpriseTrial = subscription?.is_free_trial === 'true';
@@ -124,6 +121,8 @@ function Content(props: ContentProps) {
const freeTierText = (!isStarter && !currentSubscriptionIsMonthly) ? formatMessage({id: 'pricing_modal.btn.contactSupport', defaultMessage: 'Contact Support'}) : formatMessage({id: 'pricing_modal.btn.downgrade', defaultMessage: 'Downgrade'});
const adminProfessionalTierText = currentSubscriptionIsMonthlyProfessional ? formatMessage({id: 'pricing_modal.btn.switch_to_annual', defaultMessage: 'Switch to annual billing'}) : formatMessage({id: 'pricing_modal.btn.upgrade', defaultMessage: 'Upgrade'});
const [openContactSales] = useOpenSalesLink();
const [openContactSupport] = useOpenCloudZendeskSupportForm('Workspace downgrade', '');
const openCloudPurchaseModal = useOpenCloudPurchaseModal({});
const openCloudDelinquencyModal = useOpenCloudPurchaseModal({
isDelinquencyModal: true,
@@ -239,7 +238,7 @@ function Content(props: ContentProps) {
return {
action: () => {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_PRICING, 'click_enterprise_contact_sales');
window.open(contactSalesLink, '_blank');
openContactSales();
},
text: formatMessage({id: 'pricing_modal.btn.contactSales', defaultMessage: 'Contact Sales'}),
customClass: ButtonCustomiserClasses.active,
@@ -350,7 +349,7 @@ function Content(props: ContentProps) {
buttonDetails={{
action: () => {
if (!isStarter && !currentSubscriptionIsMonthly) {
window.open(contactSupportLink, '_blank');
openContactSupport();
return;
}

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

@@ -6,7 +6,7 @@ import {Modal} from 'react-bootstrap';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {CloudLinks, LicenseLinks, ModalIdentifiers, SelfHostedProducts, LicenseSkus, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants';
import {CloudLinks, ModalIdentifiers, SelfHostedProducts, LicenseSkus, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants';
import {findSelfHostedProductBySku} from 'utils/hosted_customer';
import {trackEvent} from 'actions/telemetry_actions';
@@ -27,6 +27,7 @@ import StartTrialBtn from 'components/learn_more_trial_modal/start_trial_btn';
import ExternalLink from 'components/external_link';
import useCanSelfHostedSignup from 'components/common/hooks/useCanSelfHostedSignup';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {
useControlAirGappedSelfHostedPurchaseModal,
@@ -89,6 +90,7 @@ function SelfHostedContent(props: ContentProps) {
const isEnterprise = license.SkuShortName === LicenseSkus.Enterprise;
const isPostSelfHostedEnterpriseTrial = prevSelfHostedTrialLicense.IsLicensed === 'true';
const [openContactSales] = useOpenSalesLink();
const controlScreeningInProgressModal = useControlScreeningInProgressModal();
const controlAirgappedModal = useControlAirGappedSelfHostedPurchaseModal();
@@ -287,7 +289,7 @@ function SelfHostedContent(props: ContentProps) {
buttonDetails={(isPostSelfHostedEnterpriseTrial || !isAdmin) ? {
action: () => {
trackEvent('self_hosted_pricing', 'click_enterprise_contact_sales');
window.open(LicenseLinks.CONTACT_SALES, '_blank');
openContactSales();
},
text: formatMessage({id: 'pricing_modal.btn.contactSales', defaultMessage: 'Contact Sales'}),
customClass: ButtonCustomiserClasses.active,

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

@@ -19,7 +19,7 @@ import {GlobalState} from 'types/store';
import {BillingDetails} from 'types/cloud/sku';
import {isModalOpen} from 'selectors/views/modals';
import {getCloudContactUsLink, InquiryType, getCloudDelinquentInvoices, isCloudDelinquencyGreaterThan90Days} from 'selectors/cloud';
import {getCloudDelinquentInvoices, isCloudDelinquencyGreaterThan90Days} from 'selectors/cloud';
import {isDevModeEnabled} from 'selectors/general';
import {ModalIdentifiers} from 'utils/constants';
@@ -29,6 +29,7 @@ import {completeStripeAddPaymentMethod, subscribeCloudSubscription} from 'action
import {ModalData} from 'types/actions';
import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription';
import {findOnlyYearlyProducts} from 'utils/products';
import {getCloudContactSalesLink, getCloudSupportLink} from 'utils/contact_support_sales';
const PurchaseModal = makeAsyncComponent('PurchaseModal', React.lazy(() => import('./purchase_modal')));
@@ -39,19 +40,27 @@ function mapStateToProps(state: GlobalState) {
const products = state.entities.cloud!.products;
const yearlyProducts = findOnlyYearlyProducts(products || {});
const customer = state.entities.cloud.customer;
const customerEmail = customer?.email || '';
const firstName = customer?.contact_first_name || '';
const lastName = customer?.contact_last_name || '';
const companyName = customer?.name || '';
const contactSalesLink = getCloudContactSalesLink(firstName, lastName, companyName, customerEmail, 'mattermost', 'in-product-cloud');
const contactSupportLink = getCloudSupportLink(customerEmail, 'Cloud purchase', '', window.location.host);
return {
show: isModalOpen(state, ModalIdentifiers.CLOUD_PURCHASE),
products,
yearlyProducts,
isDevMode: isDevModeEnabled(state),
contactSupportLink: getCloudContactUsLink(state)(InquiryType.Technical),
contactSupportLink,
invoices: getCloudDelinquentInvoices(state),
isCloudDelinquencyGreaterThan90Days: isCloudDelinquencyGreaterThan90Days(state),
isFreeTrial: subscription?.is_free_trial === 'true',
isComplianceBlocked: subscription?.compliance_blocked === 'true',
contactSalesLink: getCloudContactUsLink(state)(InquiryType.Sales),
contactSalesLink,
productId: subscription?.product_id,
customer: state.entities.cloud.customer,
customer,
currentTeam: getCurrentTeam(state),
theme: getTheme(state),
isDelinquencyModal,

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

@@ -2,42 +2,8 @@
overflow: hidden;
height: 100%;
.shipping-address-section {
display: flex;
align-content: center;
padding: 0 96px;
padding-bottom: 28px;
font-weight: normal;
button.no-style {
padding-left: 0;
background: transparent;
outline: unset;
&:focus {
outline: unset;
}
}
#address-same-than-billing-address {
width: 20px;
height: 20px;
margin-top: auto;
margin-bottom: auto;
}
.Form-checkbox-label {
padding-left: 12px;
cursor: default;
font-family: 'Open Sans', sans-serif;
vertical-align: middle;
}
.billing_address_btn_text {
color: var(--center-channel-color);
font-family: 'Open Sans', sans-serif;
font-weight: normal;
}
& &__purchase-body {
overflow-y: auto;
}
>div {

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

@@ -6,6 +6,7 @@
import React, {ReactNode} from 'react';
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl';
import classnames from 'classnames';
import {Stripe, StripeCardElementChangeEvent} from '@stripe/stripe-js';
import {loadStripe} from '@stripe/stripe-js/pure'; // https://github.com/stripe/stripe-js#importing-loadstripe-without-side-effects
import {Elements} from '@stripe/react-stripe-js';
@@ -17,7 +18,6 @@ import ComplianceScreenFailedSvg from 'components/common/svg_images_components/a
import AddressForm from 'components/payment_form/address_form';
import {t} from 'utils/i18n';
import {Address, CloudCustomer, Product, Invoice, areShippingDetailsValid, Feedback} from '@mattermost/types/cloud';
import {ActionResult} from 'mattermost-redux/types/actions';
import {localizeMessage, getNextBillingDate, getBlankAddressWithCountry} from 'utils/utils';
@@ -33,6 +33,7 @@ import {
ModalIdentifiers,
RecurringIntervals,
} from 'utils/constants';
import {goToMattermostContactSalesForm} from 'utils/contact_support_sales';
import PaymentDetails from 'components/admin_console/billing/payment_details';
import {STRIPE_CSS_SRC, STRIPE_PUBLIC_KEY} from 'components/payment_form/stripe';
@@ -53,6 +54,8 @@ import {ModalData} from 'types/actions';
import {Theme} from 'mattermost-redux/selectors/entities/preferences';
import {Address, CloudCustomer, Product, Invoice, areShippingDetailsValid, Feedback} from '@mattermost/types/cloud';
import {areBillingDetailsValid, BillingDetails} from '../../types/cloud/sku';
import {Team} from '@mattermost/types/teams';
@@ -462,6 +465,7 @@ class PurchaseModal extends React.PureComponent<Props, State> {
}
confirmSwitchToAnnual = () => {
const {customer} = this.props;
this.props.actions.openModal({
modalId: ModalIdentifiers.CONFIRM_SWITCH_TO_YEARLY,
dialogType: SwitchToYearlyPlanConfirmModal,
@@ -475,7 +479,11 @@ class PurchaseModal extends React.PureComponent<Props, State> {
TELEMETRY_CATEGORIES.CLOUD_ADMIN,
'confirm_switch_to_annual_click_contact_sales',
);
window.open(this.props.contactSalesLink, '_blank');
const customerEmail = customer?.email || '';
const firstName = customer?.contact_first_name || '';
const lastName = customer?.contact_last_name || '';
const companyName = customer?.name || '';
goToMattermostContactSalesForm(firstName, lastName, companyName, customerEmail, 'mattermost', 'in-product-cloud');
},
},
});
@@ -812,7 +820,7 @@ class PurchaseModal extends React.PureComponent<Props, State> {
}
return (
<div className={this.state.processing ? 'processing' : ''}>
<div className={classnames('PurchaseModal__purchase-body', {processing: this.state.processing})}>
<div className='LHS'>
<h2 className='title'>{title}</h2>
<UpgradeSvg
@@ -1012,7 +1020,7 @@ class PurchaseModal extends React.PureComponent<Props, State> {
});
}}
contactSupportLink={
this.props.contactSalesLink
this.props.contactSupportLink
}
currentTeam={this.props.currentTeam}
onSuccess={() => {

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

@@ -0,0 +1,319 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {ArchiveOutlineIcon} from '@mattermost/compass-icons/components';
import LoadingScreen from 'components/loading_screen';
import LoadingWrapper from 'components/widgets/loading/loading_wrapper';
import QuickInput from 'components/quick_input';
import * as UserAgent from 'utils/user_agent';
import {localizeMessage} from 'utils/utils';
import LocalizedInput from 'components/localized_input/localized_input';
import SharedChannelIndicator from 'components/shared_channel_indicator';
import {t} from 'utils/i18n';
import MenuWrapper from './widgets/menu/menu_wrapper';
import Menu from './widgets/menu/menu';
const NEXT_BUTTON_TIMEOUT_MILLISECONDS = 500;
export default class SearchableChannelList extends React.PureComponent {
static getDerivedStateFromProps(props, state) {
return {isSearch: props.isSearch, page: props.isSearch && !state.isSearch ? 0 : state.page};
}
constructor(props) {
super(props);
this.nextTimeoutId = 0;
this.state = {
joiningChannel: '',
page: 0,
nextDisabled: false,
};
this.filter = React.createRef();
this.channelListScroll = React.createRef();
}
componentDidMount() {
// only focus the search box on desktop so that we don't cause the keyboard to open on mobile
if (!UserAgent.isMobile() && this.filter.current) {
this.filter.current.focus();
}
}
handleJoin(channel) {
this.setState({joiningChannel: channel.id});
this.props.handleJoin(
channel,
() => {
this.setState({joiningChannel: ''});
},
);
}
createChannelRow = (channel) => {
const ariaLabel = `${channel.display_name}, ${channel.purpose}`.toLowerCase();
let archiveIcon;
let sharedIcon;
const {shouldShowArchivedChannels} = this.props;
if (shouldShowArchivedChannels) {
archiveIcon = (
<ArchiveOutlineIcon
size={20}
color={'currentColor'}
/>
);
}
if (channel.shared) {
sharedIcon = (
<SharedChannelIndicator
className='shared-channel-icon'
channelType={channel.type}
withTooltip={true}
/>
);
}
return (
<div
className='more-modal__row'
key={channel.id}
id={`ChannelRow-${channel.name}`}
>
<div className='more-modal__details'>
<button
onClick={this.handleJoin.bind(this, channel)}
aria-label={ariaLabel}
className='style--none more-modal__name'
>
{archiveIcon}
{channel.display_name}
{sharedIcon}
</button>
<p className='more-modal__description'>{channel.purpose}</p>
</div>
<div className='more-modal__actions'>
<button
onClick={this.handleJoin.bind(this, channel)}
className='btn btn-primary'
disabled={this.state.joiningChannel}
>
<LoadingWrapper
loading={this.state.joiningChannel === channel.id}
text={localizeMessage('more_channels.joining', 'Joining...')}
>
<FormattedMessage
id={shouldShowArchivedChannels ? t('more_channels.view') : t('more_channels.join')}
defaultMessage={shouldShowArchivedChannels ? 'View' : 'Join'}
/>
</LoadingWrapper>
</button>
</div>
</div>
);
}
nextPage = (e) => {
e.preventDefault();
this.setState({page: this.state.page + 1, nextDisabled: true});
this.nextTimeoutId = setTimeout(() => this.setState({nextDisabled: false}), NEXT_BUTTON_TIMEOUT_MILLISECONDS);
this.props.nextPage(this.state.page + 1);
this.channelListScroll.current?.scrollTo({top: 0});
}
previousPage = (e) => {
e.preventDefault();
this.setState({page: this.state.page - 1});
this.channelListScroll.current?.scrollTo({top: 0});
}
doSearch = () => {
const term = this.filter.current.value;
this.props.search(term);
if (term === '') {
this.setState({page: 0});
}
}
toggleArchivedChannelsOn = () => {
this.props.toggleArchivedChannels(true);
}
toggleArchivedChannelsOff = () => {
this.props.toggleArchivedChannels(false);
}
render() {
const channels = this.props.channels;
let listContent;
let nextButton;
let previousButton;
if (this.props.loading && channels.length === 0) {
listContent = <LoadingScreen/>;
} else if (channels.length === 0) {
listContent = (
<div className='no-channel-message'>
<h3 className='primary-message'>
<FormattedMessage
id='more_channels.noMore'
tagName='strong'
defaultMessage='No more channels to join'
/>
</h3>
{this.props.noResultsText}
</div>
);
} else {
const pageStart = this.state.page * this.props.channelsPerPage;
const pageEnd = pageStart + this.props.channelsPerPage;
const channelsToDisplay = this.props.channels.slice(pageStart, pageEnd);
listContent = channelsToDisplay.map(this.createChannelRow);
if (channelsToDisplay.length >= this.props.channelsPerPage && pageEnd < this.props.channels.length) {
nextButton = (
<button
className='btn btn-link filter-control filter-control__next'
onClick={this.nextPage}
disabled={this.state.nextDisabled}
>
<FormattedMessage
id='more_channels.next'
defaultMessage='Next'
/>
</button>
);
}
if (this.state.page > 0) {
previousButton = (
<button
className='btn btn-link filter-control filter-control__prev'
onClick={this.previousPage}
>
<FormattedMessage
id='more_channels.prev'
defaultMessage='Previous'
/>
</button>
);
}
}
let input = (
<div className='filter-row filter-row--full'>
<div className='col-sm-12'>
<QuickInput
id='searchChannelsTextbox'
ref={this.filter}
className='form-control filter-textbox'
placeholder={{id: t('filtered_channels_list.search'), defaultMessage: 'Search channels'}}
inputComponent={LocalizedInput}
onInput={this.doSearch}
/>
</div>
</div>
);
if (this.props.createChannelButton) {
input = (
<div className='channel_search'>
<div className='search_input'>
<QuickInput
id='searchChannelsTextbox'
ref={this.filter}
className='form-control filter-textbox'
placeholder={{id: t('filtered_channels_list.search'), defaultMessage: 'Search channels'}}
inputComponent={LocalizedInput}
onInput={this.doSearch}
/>
</div>
<div className='create_button'>
{this.props.createChannelButton}
</div>
</div>
);
}
let channelDropdown;
if (this.props.canShowArchivedChannels) {
channelDropdown = (
<div className='more-modal__dropdown'>
<MenuWrapper id='channelsMoreDropdown'>
<a>
<span>{this.props.shouldShowArchivedChannels ? localizeMessage('more_channels.show_archived_channels', 'Show: Archived Channels') : localizeMessage('more_channels.show_public_channels', 'Show: Public Channels')}</span>
<span className='caret'/>
</a>
<Menu
openLeft={false}
ariaLabel={localizeMessage('team_members_dropdown.menuAriaLabel', 'Change the role of a team member')}
>
<Menu.ItemAction
id='channelsMoreDropdownPublic'
onClick={this.toggleArchivedChannelsOff}
text={localizeMessage('suggestion.search.public', 'Public Channels')}
/>
<Menu.ItemAction
id='channelsMoreDropdownArchived'
onClick={this.toggleArchivedChannelsOn}
text={localizeMessage('suggestion.archive', 'Archived Channels')}
/>
</Menu>
</MenuWrapper>
</div>
);
}
return (
<div className='filtered-user-list'>
{input}
{channelDropdown}
<div
role='application'
className='more-modal__list'
>
<div
id='moreChannelsList'
ref={this.channelListScroll}
>
{listContent}
</div>
</div>
<div className='filter-controls'>
{previousButton}
{nextButton}
</div>
</div>
);
}
}
SearchableChannelList.defaultProps = {
channels: [],
isSearch: false,
};
SearchableChannelList.propTypes = {
channels: PropTypes.arrayOf(PropTypes.object),
channelsPerPage: PropTypes.number,
nextPage: PropTypes.func.isRequired,
isSearch: PropTypes.bool,
search: PropTypes.func.isRequired,
handleJoin: PropTypes.func.isRequired,
noResultsText: PropTypes.object,
loading: PropTypes.bool,
createChannelButton: PropTypes.element,
toggleArchivedChannels: PropTypes.func.isRequired,
shouldShowArchivedChannels: PropTypes.bool.isRequired,
canShowArchivedChannels: PropTypes.bool.isRequired,
};

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

@@ -4,25 +4,20 @@
import React from 'react';
import {shallow} from 'enzyme';
import SearchableChannelList from './searchable_channel_list.jsx';
import SearchableChannelList from 'components/searchable_channel_list.jsx';
describe('components/SearchableChannelList', () => {
const baseProps = {
channels: [],
isSearch: false,
channelsPerPage: 10,
nextPage: jest.fn(),
search: jest.fn(),
handleJoin: jest.fn(),
nextPage: () => {}, // eslint-disable-line no-empty-function
search: () => {}, // eslint-disable-line no-empty-function
handleJoin: () => {}, // eslint-disable-line no-empty-function
loading: true,
rememberHideJoinedChannelsChecked: false,
toggleArchivedChannels: jest.fn(),
toggleArchivedChannels: () => {}, // eslint-disable-line no-empty-function
shouldShowArchivedChannels: false,
canShowArchivedChannels: false,
myChannelMemberships: {},
allChannelStats: {},
closeModal: jest.fn(),
hideJoinedChannelsPreference: jest.fn(),
};
test('should match init snapshot', () => {

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

@@ -0,0 +1,132 @@
// 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 classNames from 'classnames';
import {COUNTRIES} from 'utils/countries';
import DropdownInput from 'components/dropdown_input';
import Input from 'components/widgets/inputs/input/input';
import StateSelector from 'components/payment_form/state_selector';
interface Props {
type: 'shipping' | 'billing';
testPrefix?: string;
country: string;
changeCountry: (option: {value: string}) => void;
address: string;
changeAddress: (e: React.ChangeEvent<HTMLInputElement>) => void;
address2: string;
changeAddress2: (e: React.ChangeEvent<HTMLInputElement>) => void;
city: string;
changeCity: (e: React.ChangeEvent<HTMLInputElement>) => void;
state: string;
changeState: (postalCode: string) => void;
postalCode: string;
changePostalCode: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function Address(props: Props) {
const testPrefix = props.testPrefix || 'selfHostedPurchase';
const intl = useIntl();
let countrySelectorId = `${testPrefix}CountrySelector`;
let stateSelectorId = `${testPrefix}StateSelector`;
if (props.type === 'shipping') {
countrySelectorId += '_Shipping';
stateSelectorId += '_Shipping';
}
return (
<>
<div className={classNames({'third-dropdown-sibling-wrapper': props.type === 'shipping'})}>
<DropdownInput
testId={countrySelectorId}
onChange={props.changeCountry}
value={
props.country ? {value: props.country, label: props.country} : undefined
}
options={COUNTRIES.map((country) => ({
value: country.name,
label: country.name,
}))}
legend={intl.formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
placeholder={intl.formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
name={'billing_dropdown'}
/>
</div>
<div className='form-row'>
<Input
name='address'
type='text'
value={props.address}
onChange={props.changeAddress}
placeholder={intl.formatMessage({
id: 'payment_form.address',
defaultMessage: 'Address',
})}
required={true}
/>
</div>
<div className='form-row'>
<Input
name='address2'
type='text'
value={props.address2}
onChange={props.changeAddress2}
placeholder={intl.formatMessage({
id: 'payment_form.address_2',
defaultMessage: 'Address 2',
})}
/>
</div>
<div className='form-row'>
<Input
name='city'
type='text'
value={props.city}
onChange={props.changeCity}
placeholder={intl.formatMessage({
id: 'payment_form.city',
defaultMessage: 'City',
})}
required={true}
/>
</div>
<div className='form-row'>
<div className={classNames('form-row-third-1', {'second-dropdown-sibling-wrapper': props.type === 'billing', 'fourth-dropdown-sibling-wrapper': props.type === 'shipping'})}>
<StateSelector
testId={stateSelectorId}
country={props.country}
state={props.state}
onChange={props.changeState}
/>
</div>
<div className='form-row-third-2'>
<Input
name='postalCode'
type='text'
value={props.postalCode}
onChange={props.changePostalCode}
placeholder={intl.formatMessage({
id: 'payment_form.zipcode',
defaultMessage: 'Zip/Postal Code',
})}
required={true}
/>
</div>
</div>
</>
);
}

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

@@ -4,18 +4,17 @@
import React from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {trackEvent} from 'actions/telemetry_actions';
import {getCloudContactUsLink, InquiryType} from 'selectors/cloud';
import {
TELEMETRY_CATEGORIES,
} from 'utils/constants';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import ExternalLink from 'components/external_link';
export default function ContactSalesLink() {
const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical);
const [, contactSalesLink] = useOpenSalesLink();
const intl = useIntl();
return (
<ExternalLink
@@ -26,7 +25,7 @@ export default function ContactSalesLink() {
'click_contact_sales',
);
}}
href={contactSupportLink}
href={contactSalesLink}
location='contact_sales_link'
>
{intl.formatMessage({id: 'self_hosted_signup.contact_sales', defaultMessage: 'Contact Sales'})}

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

@@ -4,13 +4,11 @@
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {getCloudContactUsLink, InquiryType} from 'selectors/cloud';
import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg';
import AccessDeniedHappySvg from 'components/common/svg_images_components/access_denied_happy_svg';
import IconMessage from 'components/purchase_modal/icon_message';
import {useOpenSelfHostedZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import ExternalLink from 'components/external_link';
interface Props {
@@ -20,7 +18,7 @@ interface Props {
}
export default function ErrorPage(props: Props) {
const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical);
const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error');
let formattedTitle = (
<FormattedMessage
id='admin.billing.subscription.paymentVerificationFailed'

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

@@ -310,6 +310,15 @@ describe('SelfHostedPurchaseModal :: canSubmit', () => {
state: 'string',
country: 'string',
postalCode: '12345',
shippingSame: true,
shippingAddress: '',
shippingAddress2: '',
shippingCity: '',
shippingState: '',
shippingCountry: '',
shippingPostalCode: '',
cardName: 'string',
organization: 'string',
agreedTerms: true,
@@ -361,6 +370,21 @@ describe('SelfHostedPurchaseModal :: canSubmit', () => {
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if shipping address different and is not filled, can not submit', () => {
const state = makeHappyPathState();
state.shippingSame = false;
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
state.shippingAddress = 'more shipping info';
state.shippingAddress2 = 'more shipping info';
state.shippingCity = 'more shipping info';
state.shippingState = 'more shipping info';
state.shippingCountry = 'more shipping info';
state.shippingPostalCode = 'more shipping info';
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(true);
});
it('if card number missing and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.cardFilled = false;

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

@@ -26,8 +26,6 @@ import {GlobalState} from 'types/store';
import {isModalOpen} from 'selectors/views/modals';
import {isDevModeEnabled} from 'selectors/general';
import {COUNTRIES} from 'utils/countries';
import {
ModalIdentifiers,
StatTypes,
@@ -35,8 +33,6 @@ import {
} from 'utils/constants';
import CardInput, {CardInputType} from 'components/payment_form/card_input';
import StateSelector from 'components/payment_form/state_selector';
import DropdownInput from 'components/dropdown_input';
import BackgroundSvg from 'components/common/svg_images_components/background_svg';
import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg';
@@ -47,6 +43,7 @@ import RootPortal from 'components/root_portal';
import useLoadStripe from 'components/common/hooks/useLoadStripe';
import useControlSelfHostedPurchaseModal from 'components/common/hooks/useControlSelfHostedPurchaseModal';
import useFetchStandardAnalytics from 'components/common/hooks/useFetchStandardAnalytics';
import ChooseDifferentShipping from 'components/choose_different_shipping';
import {ValueOf} from '@mattermost/types/utilities';
import {UserProfile} from '@mattermost/types/users';
@@ -64,6 +61,7 @@ import SuccessPage from './success_page';
import SelfHostedCard from './self_hosted_card';
import StripeProvider from './stripe_provider';
import Terms from './terms';
import Address from './address';
import useNoEscape from './useNoEscape';
import {SetPrefix, UnionSetActions} from './types';
@@ -73,12 +71,24 @@ import './self_hosted_purchase_modal.scss';
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from './constants';
export interface State {
// billing address
address: string;
address2: string;
city: string;
state: string;
country: string;
postalCode: string;
// shipping address
shippingSame: boolean;
shippingAddress: string;
shippingAddress2: string;
shippingCity: string;
shippingState: string;
shippingCountry: string;
shippingPostalCode: string;
cardName: string;
organization: string;
agreedTerms: boolean;
@@ -113,6 +123,15 @@ export function makeInitialState(): State {
state: '',
country: '',
postalCode: '',
shippingSame: true,
shippingAddress: '',
shippingAddress2: '',
shippingCity: '',
shippingState: '',
shippingCountry: '',
shippingPostalCode: '',
cardName: '',
organization: '',
agreedTerms: false,
@@ -170,8 +189,18 @@ const simpleSetters: Array<Extract<keyof State, string>> = [
'address2',
'city',
'country',
'postalCode',
'state',
'postalCode',
// shipping address
'shippingSame',
'shippingAddress',
'shippingAddress2',
'shippingCity',
'shippingState',
'shippingCountry',
'shippingPostalCode',
'agreedTerms',
'cardFilled',
'cardName',
@@ -220,7 +249,7 @@ export function canSubmit(state: State, progress: ValueOf<typeof SelfHostedSignu
return false;
}
const validAddress = Boolean(
let validAddress = Boolean(
state.organization &&
state.address &&
state.city &&
@@ -228,6 +257,16 @@ export function canSubmit(state: State, progress: ValueOf<typeof SelfHostedSignu
state.postalCode &&
state.country,
);
if (!state.shippingSame) {
validAddress = validAddress && Boolean(
state.shippingAddress &&
state.shippingCity &&
state.shippingState &&
state.shippingPostalCode &&
state.shippingCountry,
);
}
const validCard = Boolean(
state.cardName &&
state.cardFilled,
@@ -366,16 +405,25 @@ export default function SelfHostedPurchaseModal(props: Props) {
try {
const [firstName, lastName] = inferNames(user, state.cardName);
const billingAddress = {
city: state.city,
country: state.country,
line1: state.address,
line2: state.address2,
postal_code: state.postalCode,
state: state.state,
};
signupCustomerResult = await Client4.createCustomerSelfHostedSignup({
first_name: firstName,
last_name: lastName,
billing_address: {
city: state.city,
country: state.country,
line1: state.address,
line2: state.address2,
postal_code: state.postalCode,
state: state.state,
billing_address: billingAddress,
shipping_address: state.shippingSame ? billingAddress : {
city: state.shippingCity,
country: state.shippingCountry,
line1: state.shippingAddress,
line2: state.shippingAddress2,
postal_code: state.shippingPostalCode,
state: state.shippingState,
},
organization: state.organization,
});
@@ -586,99 +634,76 @@ export default function SelfHostedPurchaseModal(props: Props) {
defaultMessage='Billing address'
/>
</div>
<DropdownInput
testId='selfHostedPurchaseCountrySelector'
onChange={(option: {value: string}) => {
<Address
type='billing'
country={state.country}
changeCountry={(option) => {
dispatch({type: 'set_country', data: option.value});
}}
value={
state.country ? {value: state.country, label: state.country} : undefined
}
options={COUNTRIES.map((country) => ({
value: country.name,
label: country.name,
}))}
legend={intl.formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
placeholder={intl.formatMessage({
id: 'payment_form.country',
defaultMessage: 'Country',
})}
name={'billing_dropdown'}
address={state.address}
changeAddress={(e) => {
dispatch({type: 'set_address', data: e.target.value});
}}
address2={state.address2}
changeAddress2={(e) => {
dispatch({type: 'set_address2', data: e.target.value});
}}
city={state.city}
changeCity={(e) => {
dispatch({type: 'set_city', data: e.target.value});
}}
state={state.state}
changeState={(state: string) => {
dispatch({type: 'set_state', data: state});
}}
postalCode={state.postalCode}
changePostalCode={(e) => {
dispatch({type: 'set_postalCode', data: e.target.value});
}}
/>
<div className='form-row'>
<Input
name='address'
type='text'
value={state.address}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({type: 'set_address', data: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.address',
defaultMessage: 'Address',
})}
required={true}
/>
</div>
<div className='form-row'>
<Input
name='address2'
type='text'
value={state.address2}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({type: 'set_address2', data: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.address_2',
defaultMessage: 'Address 2',
})}
/>
</div>
<div className='form-row'>
<Input
name='city'
type='text'
value={state.city}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({type: 'set_city', data: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.city',
defaultMessage: 'City',
})}
required={true}
/>
</div>
<div className='form-row'>
<div className='form-row-third-1'>
<StateSelector
testId='selfHostedPurchaseStateSelector'
country={state.country}
state={state.state}
onChange={(state: string) => {
dispatch({type: 'set_state', data: state});
<ChooseDifferentShipping
shippingIsSame={state.shippingSame}
setShippingIsSame={(val: boolean) => {
dispatch({type: 'set_shippingSame', data: val});
}}
/>
{!state.shippingSame && (
<>
<div className='section-title'>
<FormattedMessage
id='payment_form.shipping_address'
defaultMessage='Shipping Address'
/>
</div>
<Address
type='shipping'
country={state.shippingCountry}
changeCountry={(option) => {
dispatch({type: 'set_shippingCountry', data: option.value});
}}
address={state.shippingAddress}
changeAddress={(e) => {
dispatch({type: 'set_shippingAddress', data: e.target.value});
}}
address2={state.shippingAddress2}
changeAddress2={(e) => {
dispatch({type: 'set_shippingAddress2', data: e.target.value});
}}
city={state.shippingCity}
changeCity={(e) => {
dispatch({type: 'set_shippingCity', data: e.target.value});
}}
state={state.shippingState}
changeState={(state: string) => {
dispatch({type: 'set_shippingState', data: state});
}}
postalCode={state.shippingPostalCode}
changePostalCode={(e) => {
dispatch({type: 'set_shippingPostalCode', data: e.target.value});
}}
/>
</div>
<div className='form-row-third-2'>
<Input
name='postalCode'
type='text'
value={state.postalCode}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({type: 'set_postalCode', data: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.zipcode',
defaultMessage: 'Zip/Postal Code',
})}
required={true}
/>
</div>
</div>
</>
)}
<Terms
agreed={state.agreedTerms}
setAgreed={(data: boolean) => {

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

@@ -4,19 +4,20 @@
.form-view {
display: flex;
overflow: hidden;
width: 100%;
height: 100%;
flex-direction: row;
flex-grow: 1;
flex-wrap: wrap;
align-content: top;
align-items: flex-start;
justify-content: center;
padding: 77px 107px;
color: var(--center-channel-color);
font-family: "Open Sans";
font-size: 16px;
font-weight: 600;
overflow-x: hidden;
overflow-y: auto;
.title {
font-size: 22px;
@@ -39,14 +40,12 @@
margin-right: 16px;
.DropdownInput {
z-index: 99999;
margin-top: 0;
}
}
.DropdownInput {
position: relative;
z-index: 999999;
height: 36px;
margin-bottom: 24px;
@@ -517,6 +516,9 @@
}
input[type=checkbox] {
width: 17px;
height: 17px;
flex-shrink: 0;
margin-right: 12px;
}

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

@@ -0,0 +1,46 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/new_channel_modal should match snapshot 1`] = `
<MenuWrapper
animationComponent={[Function]}
className="AddChannelsCtaDropdown"
onToggle={[Function]}
open={false}
>
<button
aria-label="Add Channels Dropdown"
className="SidebarChannelNavigator__addChannelsCtaLhsButton SidebarChannelNavigator__addChannelsCtaLhsButton--untouched"
id="addChannelsCta"
>
<li
aria-label="Add channels"
>
<i
className="icon-plus-box"
/>
<span>
Add Channels
</span>
</li>
</button>
<Menu
ariaLabel="Add Channels Dropdown"
id="AddChannelCtaDropdown"
>
<MenuGroup>
<MenuItemAction
id="showNewChannel"
onClick={[Function]}
show={true}
text="Create New Channel"
/>
<MenuItemAction
id="showMoreChannels"
onClick={[Function]}
show={true}
text="Browse Channels"
/>
</MenuGroup>
</Menu>
</MenuWrapper>
`;

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

@@ -82,7 +82,7 @@ exports[`components/sidebar/invite_members_button should match snapshot 1`] = `
>
<li
aria-label="Invite Members"
className="SidebarChannelNavigator_inviteMembersLhsButton SidebarChannelNavigator_inviteMembersLhsButton--untouched"
className="SidebarChannelNavigator__inviteMembersLhsButton SidebarChannelNavigator__inviteMembersLhsButton--untouched"
>
<i
className="icon-plus-box"

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

@@ -189,14 +189,12 @@ const AddChannelDropdown = ({
placement='top'
overlay={tooltip}
>
<>
<button
className={'AddChannelDropdown_dropdownButton'}
aria-label={intl.formatMessage({id: 'sidebar_left.add_channel_dropdown.dropdownAriaLabel', defaultMessage: 'Add Channel Dropdown'})}
>
<i className='icon-plus'/>
</button>
</>
<button
className={'AddChannelDropdown_dropdownButton'}
aria-label={intl.formatMessage({id: 'sidebar_left.add_channel_dropdown.dropdownAriaLabel', defaultMessage: 'Add Channel Dropdown'})}
>
<i className='icon-plus'/>
</button>
</OverlayTrigger>
<Menu
id='AddChannelDropdown'

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

@@ -0,0 +1,148 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import {GlobalState} from 'types/store';
import Permissions from 'mattermost-redux/constants/permissions';
import {UsersState} from '@mattermost/types/users';
import {trackEvent} from 'actions/telemetry_actions.jsx';
import AddChannelsCtaButton from './add_channels_cta_button';
jest.mock('actions/telemetry_actions.jsx', () => {
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(
<AddChannelsCtaButton/>,
),
).toMatchSnapshot();
});
test('should find the add channels button when user has permissions', () => {
const wrapper = mountWithIntl(
<AddChannelsCtaButton/>,
);
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(
<AddChannelsCtaButton/>,
);
expect(wrapper.find('.AddChannelsCtaDropdown').exists()).toBeFalsy();
});
test('should fire dispatch to save preferences when button is clicked', () => {
const wrapper = mountWithIntl(
<AddChannelsCtaButton/>,
);
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(
<AddChannelsCtaButton/>,
);
const button = wrapper.find('.AddChannelsCtaDropdown button');
expect(mockDispatch).not.toHaveBeenCalled();
button.simulate('click');
expect(trackEvent).toHaveBeenCalledWith('ui', 'add_channels_cta_button_clicked');
});
});

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

@@ -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<DispatchFunc>();
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 = (
<Menu.ItemAction
id='showMoreChannels'
onClick={showMoreChannelsModal}
text={intl.formatMessage({id: 'sidebar_left.add_channel_dropdown.browseChannels', defaultMessage: 'Browse Channels'})}
/>
);
}
let createChannel;
if (canCreateChannel) {
createChannel = (
<Menu.ItemAction
id='showNewChannel'
onClick={showNewChannelModal}
text={intl.formatMessage({id: 'sidebar_left.add_channel_dropdown.createNewChannel', defaultMessage: 'Create New Channel'})}
/>
);
}
return (
<>
<Menu.Group>
{createChannel}
{joinPublicChannel}
</Menu.Group>
</>
);
};
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 (
<MenuWrapper
className='AddChannelsCtaDropdown'
onToggle={trackOpen}
open={isAddChannelCtaOpen}
>
<button
className={buttonClass}
id={'addChannelsCta'}
aria-label={intl.formatMessage({id: 'sidebar_left.add_channel_dropdown.dropdownAriaLabel', defaultMessage: 'Add Channels Dropdown'})}
>
<li
aria-label={intl.formatMessage({id: 'sidebar_left.sidebar_channel_navigator.addChannelsCta', defaultMessage: 'Add channels'})}
>
<i className='icon-plus-box'/>
<span>
{intl.formatMessage({id: 'sidebar_left.addChannelsCta', defaultMessage: 'Add Channels'})}
</span>
</li>
</button>
<Menu
id='AddChannelCtaDropdown'
ariaLabel={intl.formatMessage({id: 'sidebar_left.add_channel_cta_dropdown.dropdownAriaLabel', defaultMessage: 'Add Channels Dropdown'})}
>
{renderDropdownItems()}
</Menu>
</MenuWrapper>
);
};
export default AddChannelsCtaButton;

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

@@ -32,7 +32,7 @@ type Props = {
isAdmin: boolean;
}
const InviteMembersButton: React.FC<Props> = (props: Props): JSX.Element | null => {
const InviteMembersButton = (props: Props): JSX.Element | null => {
const dispatch = useDispatch<DispatchFunc>();
const intl = useIntl();
@@ -50,10 +50,10 @@ const InviteMembersButton: React.FC<Props> = (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) {

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

@@ -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<Props, State> {
);
}
let addChannelsCtaButton = null;
if (category.type === 'channels' && !category.collapsed) {
addChannelsCtaButton = (
<AddChannelsCtaButton/>
);
}
return (
<div
className={classNames('SidebarChannelGroup a11y__section', {
@@ -399,6 +408,7 @@ export default class SidebarCategory extends React.PureComponent<Props, State> {
}}
</Droppable>
{inviteMembersButton}
{addChannelsCtaButton}
</div>
);
}}

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

@@ -58,6 +58,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
disabled={false}
inputSize="large"
name="email"
onBlur={[Function]}
onChange={[Function]}
placeholder="Email address"
type="text"
@@ -75,6 +76,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
disabled={false}
inputSize="large"
name="name"
onBlur={[Function]}
onChange={[Function]}
placeholder="Choose a Username"
type="text"
@@ -87,6 +89,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
error=""
info="Must be 5-64 characters long."
inputSize="large"
onBlur={[Function]}
onChange={[Function]}
value=""
/>
@@ -206,6 +209,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
disabled={false}
inputSize="large"
name="email"
onBlur={[Function]}
onChange={[Function]}
placeholder="Email address"
type="text"
@@ -223,6 +227,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
disabled={false}
inputSize="large"
name="name"
onBlur={[Function]}
onChange={[Function]}
placeholder="Choose a Username"
type="text"
@@ -235,6 +240,7 @@ exports[`components/signup/Signup should match snapshot for all signup options e
error=""
info="Must be 5-64 characters long."
inputSize="large"
onBlur={[Function]}
onChange={[Function]}
value=""
/>

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше