Merge branch 'master' into fix-boards-webapp-unit-tests
Этот коммит содержится в:
@@ -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
Обычный файл
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'
|
||||
}
|
||||
2
webapp/boards/src/types/mattermost-webapp/index.d.ts
поставляемый
2
webapp/boards/src/types/mattermost-webapp/index.d.ts
поставляемый
@@ -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);
|
||||
|
||||
60
webapp/channels/src/components/app_bar/app_bar_marketplace.tsx
Обычный файл
60
webapp/channels/src/components/app_bar/app_bar_marketplace.tsx
Обычный файл
@@ -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={() => {
|
||||
|
||||
319
webapp/channels/src/components/searchable_channel_list.jsx
Обычный файл
319
webapp/channels/src/components/searchable_channel_list.jsx
Обычный файл
@@ -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');
|
||||
});
|
||||
});
|
||||
155
webapp/channels/src/components/sidebar/add_channels_cta_button.tsx
Обычный файл
155
webapp/channels/src/components/sidebar/add_channels_cta_button.tsx
Обычный файл
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
|
||||
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
|
||||
import Menu from 'components/widgets/menu/menu';
|
||||
import MoreChannels from 'components/more_channels';
|
||||
import NewChannelModal from 'components/new_channel_modal/new_channel_modal';
|
||||
|
||||
import {isAddChannelCtaDropdownOpen} from 'selectors/views/add_channel_dropdown';
|
||||
|
||||
import {GlobalState} from 'types/store';
|
||||
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {haveICurrentChannelPermission} from 'mattermost-redux/selectors/entities/roles';
|
||||
import {DispatchFunc} from 'mattermost-redux/types/actions';
|
||||
import Permissions from 'mattermost-redux/constants/permissions';
|
||||
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
|
||||
|
||||
import {setAddChannelCtaDropdown} from 'actions/views/add_channel_dropdown';
|
||||
import {openModal} from 'actions/views/modals';
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
|
||||
import {ModalIdentifiers, Preferences, Touched} from 'utils/constants';
|
||||
|
||||
const AddChannelsCtaButton = (): JSX.Element | null => {
|
||||
const dispatch = useDispatch<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=""
|
||||
/>
|
||||
|
||||
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Ссылка в новой задаче
Block a user