Merge branch 'MM-50966-in-product-expansion' into MM-51456-update-overage-notices

Этот коммит содержится в:
Conor Macpherson
2023-04-18 14:08:16 -04:00
коммит произвёл GitHub
родитель 64ac8f21f4 3a76a324f6
Коммит 75ea46b8bb
1883 изменённых файлов: 12058 добавлений и 9734 удалений

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

@@ -97,7 +97,7 @@
}
],
"max-lines": ["warn", {"max": 800, "skipBlankLines": true, "skipComments": true}],
"formatjs/no-multiple-whitespaces": ["error"]
"formatjs/no-multiple-whitespaces": 2
},
"overrides": [
{

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

@@ -1,56 +0,0 @@
# Mattermost Web App
# [![Mattermost](https://user-images.githubusercontent.com/7205829/137170381-fe86eef0-bccc-4fdd-8e92-b258884ebdd7.png)](https://mattermost.com)
[Mattermost](https://mattermost.com) is an open source platform for secure collaboration across the entire software development lifecycle. This repo is the primary source for core development on the Mattermost platform; it's written in Golang and React, and runs as a single Linux binary with MySQL or PostgreSQL. A new compiled version is released under an MIT license every month on the 16th.
<img width="1006" alt="mattermost-hero" src="https://user-images.githubusercontent.com/7205829/136107976-7a894c9e-290a-490d-8501-e5fdbfc3785a.png">
This repo hosts the webapp client code. If you'd like to report an issue with Mattermost, please create an issue on [mattermost-server](https://github.com/mattermost/mattermost-server), which hosts the server code. You can also look at the [Get Help With Mattermost](https://docs.mattermost.com/guides/get-help.html) guide to find the resources available to our community.
- [Product documentation](https://docs.mattermost.com/)
- [Developer documentation](https://developers.mattermost.com/)
- [Download compiled version](https://mattermost.com/download)
## Try out Mattermost
- [Join the Mattermost Contributor's server](https://community.mattermost.com/signup_user_complete/?id=codoy5s743rq5mk18i7u5ksz7e) to join community discussions about contributions, development and more
- [Get started with Mattermost Cloud](https://customers.mattermost.com/cloud/signup) to try out Mattermost
[![Deploy a Preview](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/mattermost/mattermost-heroku)
_Note: Heroku preview does not include email or persistent storage._
## Install Mattermost
- [Deploy Guide](https://docs.mattermost.com/guides/deployment.html) - Deploy Mattermost in minutes via Docker, Ubuntu, or tar.
- [Developer Machine Setup](https://developers.mattermost.com/contribute/server/developer-setup) - Follow this guide if you want to write code for Mattermost
Other Install Guides:
- [Deploy Mattermost on Docker](https://docs.mattermost.com/install/install-docker.html)
- [Mattermost Omnibus](https://docs.mattermost.com/install/installing-mattermost-omnibus.html)
- [Install Mattermost from Tar](https://docs.mattermost.com/install/install-tar.html)
- [Ubuntu 20.04 LTS](https://docs.mattermost.com/install/installing-ubuntu-2004-LTS.html)
- [Kubernetes](https://docs.mattermost.com/install/install-kubernetes.html)
- [Helm](https://docs.mattermost.com/install/install-kubernetes.html#installing-the-operators-via-helm)
- [Debian Buster](https://docs.mattermost.com/install/install-debian.html)
- [RHEL 8](https://docs.mattermost.com/install/install-rhel-8.html)
- [More server install guides](https://docs.mattermost.com/guides/deployment.html)
## Get Involved
- [Contribute to Mattermost](https://handbook.mattermost.com/contributors/contributors/ways-to-contribute)
- [Find "Help Wanted" projects](https://github.com/mattermost/mattermost-server/issues?page=1&q=is%3Aissue+is%3Aopen+%22Help+Wanted%22&utf8=%E2%9C%93)
- [Join Developer Discussion on a Mattermost Server for contributors](https://docs.mattermost.com/guides/community-chat.html)
- [Get Help With Mattermost](https://docs.mattermost.com/guides/get-help.html)
## Learn More
- [API Options - webhooks, slash commands, drivers and web service](https://api.mattermost.com/)
- [Localization Guide](https://handbook.mattermost.com/contributors/contributors/localization)
## Get the Latest News
- **Twitter** - Follow [Mattermost](https://twitter.com/Mattermost)
- **Email** - Subscribe to our [newsletter](https://mattermost.com/community-newsletter/)
Any other questions, mail us at [info@mattermost.com](mailto:info@mattermost.com). We'd love to meet you!

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

@@ -13,4 +13,4 @@ test:
before_script:
- npm ci --ignore-scripts
script:
- npm run test:speed
- npm run test

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

@@ -3,7 +3,7 @@
"browser": {
"./client/web_client.jsx": "./client/browser_web_client.jsx"
},
"version": "7.9.0",
"version": "7.9.1",
"private": true,
"dependencies": {
"@floating-ui/react-dom": "1.0.0",
@@ -51,7 +51,7 @@
"localforage": "1.10.0",
"localforage-observable": "2.1.1",
"lodash": "4.17.21",
"luxon": "3.0.4",
"luxon": "3.3.0",
"mark.js": "8.11.1",
"marked": "github:mattermost/marked#2ef7f28cc7718e3f551c4ce9ea75fdd7580c2008",
"memoize-one": "6.0.0",
@@ -70,7 +70,7 @@
"react-day-picker": "8.3.6",
"react-dom": "17.0.2",
"react-hot-loader": "4.13.0",
"react-intl": "5.20.10",
"react-intl": "*",
"react-is": "17.0.2",
"react-overlays": "0.9.3",
"react-popper": "2.3.0",
@@ -116,6 +116,7 @@
"@redux-devtools/extension": "3.2.3",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.4",
"@testing-library/user-event": "12.1.4",
"@types/bootstrap": "4.5.0",
"@types/country-list": "2.1.0",
"@types/enzyme": "3.10.11",
@@ -155,14 +156,12 @@
"babel-plugin-typescript-to-proptypes": "1.4.2",
"bundle-loader": "0.2.0",
"copy-webpack-plugin": "11.0.0",
"cross-env": "7.0.3",
"css-loader": "5.2.6",
"dotenv-webpack": "8.0.1",
"enzyme": "3.11.0",
"enzyme-adapter-react-17-updated": "1.0.2",
"enzyme-to-json": "3.6.2",
"eslint-import-resolver-webpack": "0.13.2",
"eslint-plugin-formatjs": "4.9.1",
"eslint-plugin-header": "3.1.1",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-mattermost": "github:mattermost/eslint-plugin-mattermost#5b0c972eacf19286e4c66221b39113bf8728a99e",
@@ -215,15 +214,13 @@
"build": "cross-env NODE_ENV=production webpack",
"run": "webpack --progress --watch",
"dev-server": "webpack serve --mode development",
"test": "cross-env TZ=Etc/UTC jest --maxWorkers=50%",
"test": "cross-env TZ=Etc/UTC jest",
"test:watch": "cross-env TZ=Etc/UTC jest --watch",
"test:updatesnapshot": "cross-env TZ=Etc/UTC jest --updateSnapshot",
"test:debug": "cross-env TZ=Etc/UTC jest --forceExit --detectOpenHandles --verbose",
"test-ci": "cross-env TZ=Etc/UTC jest --ci --maxWorkers=100%",
"clean": "rm -rf dist node_modules .eslintcache .stylelintcache tsconfig.tsbuildinfo",
"stats": "cross-env NODE_ENV=production webpack --profile --json > webpack_stats.json",
"updatesnapshot": "cross-env TZ=Etc/UTC jest --updateSnapshot",
"test:debug": "cross-env TZ=Etc/UTC jest --forceExit --detectOpenHandles",
"test:speed": "cross-env TZ=Etc/UTC jest",
"test:watch": "cross-env TZ=Etc/UTC jest --watch",
"test:coverage": "cross-env TZ=Etc/UTC jest --coverage",
"mmjstool": "mmjstool",
"i18n-extract": "npm run mmjstool -- i18n extract-webapp --webapp-dir ./src",
"i18n-clean-empty": "npm run mmjstool -- i18n clean-empty --webapp-dir ./src",

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

@@ -175,7 +175,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFunc {
const errorResponse = res.error;
return createErrorMessage(errorResponse.text || intlShim.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error.',
defaultMessage: 'Unknown error occurred.',
}));
}
@@ -201,7 +201,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFunc {
));
}
} catch (err: any) {
return createErrorMessage(err.message || localizeMessage('apps.error.unknown', 'Unknown error.'));
return createErrorMessage(err.message || localizeMessage('apps.error.unknown', 'Unknown error occurred.'));
}
}
}

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

@@ -14,9 +14,11 @@ import {isSystemMessage, isUserAddedInChannel} from 'mattermost-redux/utils/post
import {displayUsername} from 'mattermost-redux/utils/user_utils';
import {isThreadOpen} from 'selectors/views/threads';
import {getChannelURL, getPermalinkURL} from 'selectors/urls';
import {getHistory} from 'utils/browser_history';
import Constants, {NotificationLevels, UserStatuses} from 'utils/constants';
import * as NotificationSounds from 'utils/notification_sounds';
import {showNotification} from 'utils/notifications';
import {isDesktopApp, isMobileApp, isWindowsApp} from 'utils/user_agent';
import * as Utils from 'utils/utils';
@@ -178,17 +180,17 @@ export function sendDesktopNotification(post, msgProps) {
if (notify) {
const updatedState = getState();
let url = Utils.getChannelURL(updatedState, channel, teamId);
let url = getChannelURL(updatedState, channel, teamId);
if (isCrtReply) {
url = Utils.getPermalinkURL(updatedState, teamId, post.id);
url = getPermalinkURL(updatedState, teamId, post.id);
}
dispatch(notifyMe(title, body, channel, teamId, !sound, soundName, url));
//Don't add extra sounds on native desktop clients
if (sound && !isDesktopApp() && !isMobileApp()) {
Utils.ding(soundName);
NotificationSounds.ding(soundName);
}
}
};

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

@@ -5,8 +5,8 @@ import testConfigureStore from 'tests/test_store';
import {getHistory} from 'utils/browser_history';
import Constants, {NotificationLevels, UserStatuses} from 'utils/constants';
import * as NotificationSounds from 'utils/notification_sounds';
import * as utils from 'utils/notifications';
import * as baseUtils from 'utils/utils';
import {sendDesktopNotification} from './notification_actions';
@@ -22,7 +22,7 @@ describe('notification_actions', () => {
beforeEach(() => {
spy = jest.spyOn(utils, 'showNotification');
baseUtils.ding = jest.fn();
NotificationSounds.ding = jest.fn();
crt = {
user_id: 'current_user_id',
@@ -315,7 +315,7 @@ describe('notification_actions', () => {
});
test('should default sound when no sound is specified', () => {
const dingSpy = jest.spyOn(baseUtils, 'ding');
const dingSpy = jest.spyOn(NotificationSounds, 'ding');
baseState.entities.users.profiles.current_user_id.notify_props.desktop_sound = 'true';
const store = testConfigureStore(baseState);
return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => {
@@ -324,7 +324,7 @@ describe('notification_actions', () => {
});
test('should use specified sound when specified', () => {
const dingSpy = jest.spyOn(baseUtils, 'ding');
const dingSpy = jest.spyOn(NotificationSounds, 'ding');
baseState.entities.users.profiles.current_user_id.notify_props.desktop_sound = 'true';
baseState.entities.users.profiles.current_user_id.notify_props.desktop_notification_sound = 'Crackle';
const store = testConfigureStore(baseState);

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

@@ -18,7 +18,7 @@ import {
makeOnSubmit,
makeOnEditLatestPost,
} from 'actions/views/create_comment';
import {removeDraft} from 'actions/views/drafts';
import {removeDraft, setGlobalDraftSource} from 'actions/views/drafts';
import {setGlobalItem, actionOnGlobalItemsWithPrefix} from 'actions/storage';
import * as PostActions from 'actions/post_actions';
import {executeCommand} from 'actions/command';
@@ -205,12 +205,13 @@ describe('rhs view actions', () => {
const testStore = mockStore(initialState);
testStore.dispatch(setGlobalItem(`${StoragePrefixes.COMMENT_DRAFT}${rootId}`, {
const expectedKey = `${StoragePrefixes.COMMENT_DRAFT}${rootId}`;
testStore.dispatch(setGlobalItem(expectedKey, {
...draft,
createAt: 42,
updateAt: 42,
remote: false,
}));
testStore.dispatch(setGlobalDraftSource(expectedKey, false));
expect(store.getActions()).toEqual(testStore.getActions());
jest.useRealTimers();

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

@@ -13,7 +13,7 @@ import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import {Client4} from 'mattermost-redux/client';
import {removeDraft, updateDraft} from './drafts';
import {removeDraft, setGlobalDraftSource, updateDraft} from './drafts';
jest.mock('mattermost-redux/client', () => {
const original = jest.requireActual('mattermost-redux/client');
@@ -146,12 +146,13 @@ describe('draft actions', () => {
const testStore = mockStore(initialState);
testStore.dispatch(setGlobalItem(StoragePrefixes.DRAFT + channelId, {
const expectedKey = StoragePrefixes.DRAFT + channelId;
testStore.dispatch(setGlobalItem(expectedKey, {
...draft,
createAt: 42,
updateAt: 42,
remote: false,
}));
testStore.dispatch(setGlobalDraftSource(expectedKey, false));
expect(store.getActions()).toEqual(testStore.getActions());
jest.useRealTimers();

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

@@ -15,7 +15,7 @@ import {PostDraft} from 'types/store/draft';
import {getGlobalItem} from 'selectors/storage';
import {makeGetDrafts} from 'selectors/drafts';
import {StoragePrefixes} from 'utils/constants';
import {ActionTypes, StoragePrefixes} from 'utils/constants';
import type {Draft as ServerDraft} from '@mattermost/types/drafts';
import type {UserProfile} from '@mattermost/types/users';
@@ -101,11 +101,10 @@ export function updateDraft(key: string, value: PostDraft|null, rootId = '', sav
...value,
createAt: data.createAt || timestamp,
updateAt: timestamp,
remote: false,
};
}
dispatch(setGlobalItem(key, updatedValue));
dispatch(setGlobalDraft(key, updatedValue, false));
if (syncedDraftsAreAllowedAndEnabled(state) && save && updatedValue) {
const connectionId = getConnectionId(state);
@@ -153,6 +152,24 @@ export function setDraftsTourTipPreference(initializationState: Record<string, b
};
}
export function setGlobalDraft(key: string, value: PostDraft|null, isRemote: boolean) {
return (dispatch: DispatchFunc) => {
dispatch(setGlobalItem(key, value));
dispatch(setGlobalDraftSource(key, isRemote));
return {data: true};
};
}
export function setGlobalDraftSource(key: string, isRemote: boolean) {
return {
type: ActionTypes.SET_DRAFT_SOURCE,
data: {
key,
isRemote,
},
};
}
export function transformServerDraft(draft: ServerDraft): Draft {
let key: Draft['key'] = `${StoragePrefixes.DRAFT}${draft.channel_id}`;

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

@@ -68,7 +68,7 @@ import {
} from 'mattermost-redux/actions/users';
import {removeNotVisibleUsers} from 'mattermost-redux/actions/websocket';
import {setGlobalItem} from 'actions/storage';
import {transformServerDraft} from 'actions/views/drafts';
import {setGlobalDraft, transformServerDraft} from 'actions/views/drafts';
import {Client4} from 'mattermost-redux/client';
import {getCurrentUser, getCurrentUserId, getUser, getIsManualStatusForUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
@@ -89,7 +89,6 @@ import {getStandardAnalytics} from 'mattermost-redux/actions/admin';
import {fetchAppBindings, fetchRHSAppsBindings} from 'mattermost-redux/actions/apps';
import {getConnectionId} from 'selectors/general';
import {getSelectedChannelId, getSelectedPost} from 'selectors/rhs';
import {isThreadOpen, isThreadManuallyUnread} from 'selectors/views/threads';
@@ -1700,20 +1699,12 @@ function handlePostAcknowledgementRemoved(msg) {
}
function handleUpsertDraftEvent(msg) {
return async (doDispatch, doGetState) => {
const state = doGetState();
const connectionId = getConnectionId(state);
return async (doDispatch) => {
const draft = JSON.parse(msg.data.draft);
const {key, value} = transformServerDraft(draft);
value.show = true;
value.remote = false;
if (msg.broadcast.omit_connection_id !== connectionId) {
value.remote = true;
}
doDispatch(setGlobalItem(key, value));
doDispatch(setGlobalDraft(key, value, true));
};
}
@@ -1722,7 +1713,11 @@ function handleDeleteDraftEvent(msg) {
const draft = JSON.parse(msg.data.draft);
const {key} = transformServerDraft(draft);
doDispatch(setGlobalItem(key, {message: '', fileInfos: [], uploadsInProgress: [], remote: true}));
doDispatch(setGlobalItem(key, {
message: '',
fileInfos: [],
uploadsInProgress: [],
}));
};
}

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

@@ -4,6 +4,7 @@ exports[`components/FormattedMarkdownMessage should allow to disable links 1`] =
<IntlProvider
defaultFormats={Object {}}
defaultLocale="en"
fallbackOnEmptyString={true}
formats={Object {}}
locale="en"
messages={
@@ -14,6 +15,7 @@ exports[`components/FormattedMarkdownMessage should allow to disable links 1`] =
}
}
onError={[Function]}
onWarn={[Function]}
textComponent={Symbol(react.fragment)}
>
<FormattedMarkdownMessage
@@ -41,6 +43,7 @@ exports[`components/FormattedMarkdownMessage should backup to default 1`] = `
<IntlProvider
defaultFormats={Object {}}
defaultLocale="en"
fallbackOnEmptyString={true}
formats={Object {}}
locale="en"
messages={
@@ -51,6 +54,7 @@ exports[`components/FormattedMarkdownMessage should backup to default 1`] = `
}
}
onError={[Function]}
onWarn={[Function]}
textComponent={Symbol(react.fragment)}
>
<FormattedMarkdownMessage
@@ -72,6 +76,7 @@ exports[`components/FormattedMarkdownMessage should escape non-BR 1`] = `
<IntlProvider
defaultFormats={Object {}}
defaultLocale="en"
fallbackOnEmptyString={true}
formats={Object {}}
locale="en"
messages={
@@ -82,6 +87,7 @@ exports[`components/FormattedMarkdownMessage should escape non-BR 1`] = `
}
}
onError={[Function]}
onWarn={[Function]}
textComponent={Symbol(react.fragment)}
>
<FormattedMarkdownMessage
@@ -109,6 +115,7 @@ exports[`components/FormattedMarkdownMessage should render message 1`] = `
<IntlProvider
defaultFormats={Object {}}
defaultLocale="en"
fallbackOnEmptyString={true}
formats={Object {}}
locale="en"
messages={
@@ -119,6 +126,7 @@ exports[`components/FormattedMarkdownMessage should render message 1`] = `
}
}
onError={[Function]}
onWarn={[Function]}
textComponent={Symbol(react.fragment)}
>
<FormattedMarkdownMessage
@@ -140,6 +148,7 @@ exports[`components/FormattedMarkdownMessage values should work 1`] = `
<IntlProvider
defaultFormats={Object {}}
defaultLocale="en"
fallbackOnEmptyString={true}
formats={Object {}}
locale="en"
messages={
@@ -150,6 +159,7 @@ exports[`components/FormattedMarkdownMessage values should work 1`] = `
}
}
onError={[Function]}
onWarn={[Function]}
textComponent={Symbol(react.fragment)}
>
<FormattedMarkdownMessage

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

@@ -34,7 +34,7 @@ exports[`components/SettingItemMin should match snapshot with active Save button
>
<MemoizedFormattedMessage
defaultMessage="Upload a picture in BMP, JPG or PNG format. Maximum file size: {max}"
id="setting_picture.help.profile"
id="setting_picture.help.profile.example"
values={
Object {
"max": 52428800,
@@ -139,7 +139,7 @@ exports[`components/SettingItemMin should match snapshot with active Save button
>
<MemoizedFormattedMessage
defaultMessage="Upload a picture in BMP, JPG or PNG format. Maximum file size: {max}"
id="setting_picture.help.profile"
id="setting_picture.help.profile.example"
values={
Object {
"max": 52428800,
@@ -254,7 +254,7 @@ exports[`components/SettingItemMin should match snapshot, on loading picture 1`]
>
<MemoizedFormattedMessage
defaultMessage="Upload a picture in BMP, JPG or PNG format. Maximum file size: {max}"
id="setting_picture.help.profile"
id="setting_picture.help.profile.example"
values={
Object {
"max": 52428800,
@@ -381,7 +381,7 @@ exports[`components/SettingItemMin should match snapshot, profile picture on fil
>
<MemoizedFormattedMessage
defaultMessage="Upload a picture in BMP, JPG or PNG format. Maximum file size: {max}"
id="setting_picture.help.profile"
id="setting_picture.help.profile.example"
values={
Object {
"max": 52428800,
@@ -496,7 +496,7 @@ exports[`components/SettingItemMin should match snapshot, profile picture on sou
>
<MemoizedFormattedMessage
defaultMessage="Upload a picture in BMP, JPG or PNG format. Maximum file size: {max}"
id="setting_picture.help.profile"
id="setting_picture.help.profile.example"
values={
Object {
"max": 52428800,
@@ -623,7 +623,7 @@ exports[`components/SettingItemMin should match snapshot, team icon on file 1`]
>
<MemoizedFormattedMessage
defaultMessage="Upload a picture in BMP, JPG or PNG format. Maximum file size: {max}"
id="setting_picture.help.profile"
id="setting_picture.help.profile.example"
values={
Object {
"max": 52428800,
@@ -792,7 +792,7 @@ exports[`components/SettingItemMin should match snapshot, team icon on source 1`
>
<MemoizedFormattedMessage
defaultMessage="Upload a picture in BMP, JPG or PNG format. Maximum file size: {max}"
id="setting_picture.help.profile"
id="setting_picture.help.profile.example"
values={
Object {
"max": 52428800,
@@ -961,7 +961,7 @@ exports[`components/SettingItemMin should match snapshot, user icon on source 1`
>
<MemoizedFormattedMessage
defaultMessage="Upload a picture in BMP, JPG or PNG format. Maximum file size: {max}"
id="setting_picture.help.profile"
id="setting_picture.help.profile.example"
values={
Object {
"max": 52428800,

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

@@ -1,980 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/AboutBuildModal should match snapshot for cloud edition 1`] = `
<ContextProvider
value={
Object {
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"subscription": Subscription {
"handleChangeWrapper": [Function],
"listeners": Object {
"notify": [Function],
},
"onStateChange": [Function],
"parentSub": undefined,
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"unsubscribe": null,
},
}
}
>
<AboutBuildModalCloud
config={
Object {
"BuildDate": "21 January 2017",
"BuildEnterpriseReady": "true",
"BuildHash": "abcdef1234567890",
"BuildHashEnterprise": "0123456789abcdef",
"BuildNumber": "3.6.2",
"PrivacyPolicyLink": "https://about.custom.com/privacy-policy/",
"SQLDriverName": "Postgres",
"SchemaVersion": "77",
"TermsOfServiceLink": "https://about.custom.com/default-terms/",
"Version": "3.6.0",
}
}
doHide={[MockFunction]}
license={
Object {
"Cloud": "true",
"Company": "Mattermost Inc",
"IsLicensed": "true",
}
}
onExited={[MockFunction]}
show={true}
/>
</ContextProvider>
`;
exports[`components/AboutBuildModal should match snapshot for enterprise edition 1`] = `
<Modal
animation={true}
aria-labelledby="aboutModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal about-modal"
dialogComponentClass={[Function]}
enforceFocus={true}
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="dialog"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="aboutModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="About {appTitle}"
id="about.title"
values={
Object {
"appTitle": "Mattermost",
}
}
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<div
className="about-modal__content"
>
<div
className="about-modal__logo"
>
<MattermostLogo />
</div>
<div>
<h3
className="about-modal__title"
>
<strong>
Mattermost
<MemoizedFormattedMessage
defaultMessage="Enterprise Edition"
id="about.enterpriseEditione1"
/>
</strong>
</h3>
<p
className="about-modal__subtitle pb-2"
>
<MemoizedFormattedMessage
defaultMessage="Modern communication from behind your firewall."
id="about.enterpriseEditionSt"
/>
</p>
<div
className="form-group less"
>
<div>
<MemoizedFormattedMessage
defaultMessage="Mattermost Version:"
id="about.version"
/>
<span
id="versionString"
>
 3.6.2
</span>
</div>
<div>
<MemoizedFormattedMessage
defaultMessage="Database Schema Version:"
id="about.dbversion"
/>
<span
id="dbversionString"
>
 77
</span>
</div>
<div>
<MemoizedFormattedMessage
defaultMessage="Database:"
id="about.database"
/>
 Postgres
</div>
</div>
<div
className="form-group"
>
<MemoizedFormattedMessage
defaultMessage="Licensed to:"
id="about.licensed"
/>
<Nbsp />
Mattermost Inc
</div>
</div>
</div>
<div
className="about-modal__footer"
>
<div>
<MemoizedFormattedMessage
defaultMessage="Learn more about Enterprise Edition at "
id="about.enterpriseEditionLearn"
/>
<ExternalLink
href="https://mattermost.com/"
location="about_build_modal"
>
mattermost.com
</ExternalLink>
</div>
<div
className="form-group"
>
<div
className="about-modal__copyright"
>
<MemoizedFormattedMessage
defaultMessage="Copyright 2015 - {currentYear} Mattermost, Inc. All rights reserved"
id="about.copyright"
values={
Object {
"currentYear": 2017,
}
}
/>
</div>
<div
className="about-modal__links"
>
<ExternalLink
href="https://mattermost.com/terms-of-use/"
id="tosLink"
location="about_build_modal"
>
<MemoizedFormattedMessage
defaultMessage="Terms of Use"
id="about.tos"
/>
</ExternalLink>
-
<ExternalLink
href="https://mattermost.com/privacy-policy/"
id="privacyLink"
location="about_build_modal"
>
<MemoizedFormattedMessage
defaultMessage="Privacy Policy"
id="about.privacy"
/>
</ExternalLink>
</div>
</div>
</div>
<div
className="about-modal__notice form-group pt-3"
>
<p>
<MemoizedFormattedMessage
defaultMessage="Mattermost is made possible by the open source software used in our <linkServer>server</linkServer>, <linkDesktop>desktop</linkDesktop> and <linkMobile>mobile</linkMobile> apps."
id="about.notice"
values={
Object {
"linkDesktop": [Function],
"linkMobile": [Function],
"linkServer": [Function],
}
}
/>
</p>
</div>
<div
className="about-modal__hash"
>
<p>
<MemoizedFormattedMessage
defaultMessage="Build Hash:"
id="about.hash"
/>
<Nbsp />
abcdef1234567890
<br />
<MemoizedFormattedMessage
defaultMessage="EE Build Hash:"
id="about.hashee"
/>
<Nbsp />
0123456789abcdef
</p>
<p>
<MemoizedFormattedMessage
defaultMessage="Build Date:"
id="about.date"
/>
<Nbsp />
21 January 2017
</p>
</div>
</ModalBody>
</Modal>
`;
exports[`components/AboutBuildModal should match snapshot for team edition 1`] = `
<Modal
animation={true}
aria-labelledby="aboutModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal about-modal"
dialogComponentClass={[Function]}
enforceFocus={true}
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="dialog"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="aboutModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="About {appTitle}"
id="about.title"
values={
Object {
"appTitle": "Mattermost",
}
}
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<div
className="about-modal__content"
>
<div
className="about-modal__logo"
>
<MattermostLogo />
</div>
<div>
<h3
className="about-modal__title"
>
<strong>
Mattermost
<MemoizedFormattedMessage
defaultMessage="Team Edition"
id="about.teamEditiont0"
/>
</strong>
</h3>
<p
className="about-modal__subtitle pb-2"
>
<MemoizedFormattedMessage
defaultMessage="All your team communication in one place, instantly searchable and accessible anywhere."
id="about.teamEditionSt"
/>
</p>
<div
className="form-group less"
>
<div>
<MemoizedFormattedMessage
defaultMessage="Mattermost Version:"
id="about.version"
/>
<span
id="versionString"
>
 3.6.2
</span>
</div>
<div>
<MemoizedFormattedMessage
defaultMessage="Database Schema Version:"
id="about.dbversion"
/>
<span
id="dbversionString"
>
 77
</span>
</div>
<div>
<MemoizedFormattedMessage
defaultMessage="Database:"
id="about.database"
/>
 Postgres
</div>
</div>
</div>
</div>
<div
className="about-modal__footer"
>
<div>
<MemoizedFormattedMessage
defaultMessage="Join the Mattermost community at "
id="about.teamEditionLearn"
/>
<ExternalLink
href="https://mattermost.com/community/"
location="about_build_modal"
>
mattermost.com/community/
</ExternalLink>
</div>
<div
className="form-group"
>
<div
className="about-modal__copyright"
>
<MemoizedFormattedMessage
defaultMessage="Copyright 2015 - {currentYear} Mattermost, Inc. All rights reserved"
id="about.copyright"
values={
Object {
"currentYear": 2017,
}
}
/>
</div>
<div
className="about-modal__links"
>
<ExternalLink
href="https://mattermost.com/terms-of-use/"
id="tosLink"
location="about_build_modal"
>
<MemoizedFormattedMessage
defaultMessage="Terms of Use"
id="about.tos"
/>
</ExternalLink>
-
<ExternalLink
href="https://mattermost.com/privacy-policy/"
id="privacyLink"
location="about_build_modal"
>
<MemoizedFormattedMessage
defaultMessage="Privacy Policy"
id="about.privacy"
/>
</ExternalLink>
</div>
</div>
</div>
<div
className="about-modal__notice form-group pt-3"
>
<p>
<MemoizedFormattedMessage
defaultMessage="Mattermost is made possible by the open source software used in our <linkServer>server</linkServer>, <linkDesktop>desktop</linkDesktop> and <linkMobile>mobile</linkMobile> apps."
id="about.notice"
values={
Object {
"linkDesktop": [Function],
"linkMobile": [Function],
"linkServer": [Function],
}
}
/>
</p>
</div>
<div
className="about-modal__hash"
>
<p>
<MemoizedFormattedMessage
defaultMessage="Build Hash:"
id="about.hash"
/>
<Nbsp />
abcdef1234567890
<br />
<MemoizedFormattedMessage
defaultMessage="EE Build Hash:"
id="about.hashee"
/>
<Nbsp />
</p>
<p>
<MemoizedFormattedMessage
defaultMessage="Build Date:"
id="about.date"
/>
<Nbsp />
21 January 2017
</p>
</div>
</ModalBody>
</Modal>
`;
exports[`components/AboutBuildModal should show ci if a ci build 1`] = `
<Modal
animation={true}
aria-labelledby="aboutModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal about-modal"
dialogComponentClass={[Function]}
enforceFocus={true}
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="dialog"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="aboutModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="About {appTitle}"
id="about.title"
values={
Object {
"appTitle": "Mattermost",
}
}
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<div
className="about-modal__content"
>
<div
className="about-modal__logo"
>
<MattermostLogo />
</div>
<div>
<h3
className="about-modal__title"
>
<strong>
Mattermost
<MemoizedFormattedMessage
defaultMessage="Team Edition"
id="about.teamEditiont0"
/>
</strong>
</h3>
<p
className="about-modal__subtitle pb-2"
>
<MemoizedFormattedMessage
defaultMessage="All your team communication in one place, instantly searchable and accessible anywhere."
id="about.teamEditionSt"
/>
</p>
<div
className="form-group less"
>
<div>
<MemoizedFormattedMessage
defaultMessage="Mattermost Version:"
id="about.version"
/>
<span
id="versionString"
>
 ci
</span>
</div>
<div>
<MemoizedFormattedMessage
defaultMessage="Database Schema Version:"
id="about.dbversion"
/>
<span
id="dbversionString"
>
 77
</span>
</div>
<div>
<MemoizedFormattedMessage
defaultMessage="Build Number:"
id="about.buildnumber"
/>
<span
id="buildnumberString"
>
 123
</span>
</div>
<div>
<MemoizedFormattedMessage
defaultMessage="Database:"
id="about.database"
/>
 Postgres
</div>
</div>
</div>
</div>
<div
className="about-modal__footer"
>
<div>
<MemoizedFormattedMessage
defaultMessage="Join the Mattermost community at "
id="about.teamEditionLearn"
/>
<ExternalLink
href="https://mattermost.com/community/"
location="about_build_modal"
>
mattermost.com/community/
</ExternalLink>
</div>
<div
className="form-group"
>
<div
className="about-modal__copyright"
>
<MemoizedFormattedMessage
defaultMessage="Copyright 2015 - {currentYear} Mattermost, Inc. All rights reserved"
id="about.copyright"
values={
Object {
"currentYear": 2017,
}
}
/>
</div>
<div
className="about-modal__links"
>
<ExternalLink
href="https://mattermost.com/terms-of-use/"
id="tosLink"
location="about_build_modal"
>
<MemoizedFormattedMessage
defaultMessage="Terms of Use"
id="about.tos"
/>
</ExternalLink>
-
<ExternalLink
href="https://mattermost.com/privacy-policy/"
id="privacyLink"
location="about_build_modal"
>
<MemoizedFormattedMessage
defaultMessage="Privacy Policy"
id="about.privacy"
/>
</ExternalLink>
</div>
</div>
</div>
<div
className="about-modal__notice form-group pt-3"
>
<p>
<MemoizedFormattedMessage
defaultMessage="Mattermost is made possible by the open source software used in our <linkServer>server</linkServer>, <linkDesktop>desktop</linkDesktop> and <linkMobile>mobile</linkMobile> apps."
id="about.notice"
values={
Object {
"linkDesktop": [Function],
"linkMobile": [Function],
"linkServer": [Function],
}
}
/>
</p>
</div>
<div
className="about-modal__hash"
>
<p>
<MemoizedFormattedMessage
defaultMessage="Build Hash:"
id="about.hash"
/>
<Nbsp />
abcdef1234567890
<br />
<MemoizedFormattedMessage
defaultMessage="EE Build Hash:"
id="about.hashee"
/>
<Nbsp />
</p>
<p>
<MemoizedFormattedMessage
defaultMessage="Build Date:"
id="about.date"
/>
<Nbsp />
21 January 2017
</p>
</div>
</ModalBody>
</Modal>
`;
exports[`components/AboutBuildModal should show dev if this is a dev build 1`] = `
<Modal
animation={true}
aria-labelledby="aboutModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal about-modal"
dialogComponentClass={[Function]}
enforceFocus={true}
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="dialog"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="aboutModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="About {appTitle}"
id="about.title"
values={
Object {
"appTitle": "Mattermost",
}
}
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<div
className="about-modal__content"
>
<div
className="about-modal__logo"
>
<MattermostLogo />
</div>
<div>
<h3
className="about-modal__title"
>
<strong>
Mattermost
<MemoizedFormattedMessage
defaultMessage="Team Edition"
id="about.teamEditiont0"
/>
</strong>
</h3>
<p
className="about-modal__subtitle pb-2"
>
<MemoizedFormattedMessage
defaultMessage="All your team communication in one place, instantly searchable and accessible anywhere."
id="about.teamEditionSt"
/>
</p>
<div
className="form-group less"
>
<div>
<MemoizedFormattedMessage
defaultMessage="Mattermost Version:"
id="about.version"
/>
<span
id="versionString"
>
 dev
</span>
</div>
<div>
<MemoizedFormattedMessage
defaultMessage="Database Schema Version:"
id="about.dbversion"
/>
<span
id="dbversionString"
>
 77
</span>
</div>
<div>
<MemoizedFormattedMessage
defaultMessage="Database:"
id="about.database"
/>
 Postgres
</div>
</div>
</div>
</div>
<div
className="about-modal__footer"
>
<div>
<MemoizedFormattedMessage
defaultMessage="Join the Mattermost community at "
id="about.teamEditionLearn"
/>
<ExternalLink
href="https://mattermost.com/community/"
location="about_build_modal"
>
mattermost.com/community/
</ExternalLink>
</div>
<div
className="form-group"
>
<div
className="about-modal__copyright"
>
<MemoizedFormattedMessage
defaultMessage="Copyright 2015 - {currentYear} Mattermost, Inc. All rights reserved"
id="about.copyright"
values={
Object {
"currentYear": 2017,
}
}
/>
</div>
<div
className="about-modal__links"
>
<ExternalLink
href="https://mattermost.com/terms-of-use/"
id="tosLink"
location="about_build_modal"
>
<MemoizedFormattedMessage
defaultMessage="Terms of Use"
id="about.tos"
/>
</ExternalLink>
-
<ExternalLink
href="https://mattermost.com/privacy-policy/"
id="privacyLink"
location="about_build_modal"
>
<MemoizedFormattedMessage
defaultMessage="Privacy Policy"
id="about.privacy"
/>
</ExternalLink>
</div>
</div>
</div>
<div
className="about-modal__notice form-group pt-3"
>
<p>
<MemoizedFormattedMessage
defaultMessage="Mattermost is made possible by the open source software used in our <linkServer>server</linkServer>, <linkDesktop>desktop</linkDesktop> and <linkMobile>mobile</linkMobile> apps."
id="about.notice"
values={
Object {
"linkDesktop": [Function],
"linkMobile": [Function],
"linkServer": [Function],
}
}
/>
</p>
</div>
<div
className="about-modal__hash"
>
<p>
<MemoizedFormattedMessage
defaultMessage="Build Hash:"
id="about.hash"
/>
<Nbsp />
abcdef1234567890
<br />
<MemoizedFormattedMessage
defaultMessage="EE Build Hash:"
id="about.hashee"
/>
<Nbsp />
</p>
<p>
<MemoizedFormattedMessage
defaultMessage="Build Date:"
id="about.date"
/>
<Nbsp />
21 January 2017
</p>
</div>
</ModalBody>
</Modal>
`;

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

@@ -2,8 +2,6 @@
// See LICENSE.txt for license information.
import React from 'react';
import {Modal} from 'react-bootstrap';
import {shallow} from 'enzyme';
import {Provider} from 'react-redux';
import mockStore from 'tests/test_store';
@@ -12,11 +10,13 @@ import {ClientConfig, ClientLicense} from '@mattermost/types/config';
import AboutBuildModal from 'components/about_build_modal/about_build_modal';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import {AboutLinks} from 'utils/constants';
import AboutBuildModalCloud from './about_build_modal_cloud/about_build_modal_cloud';
import {screen} from '@testing-library/react';
import {renderWithIntl} from 'tests/react_testing_utils';
import store from 'stores/redux_store';
import userEvent from '@testing-library/user-event';
describe('components/AboutBuildModal', () => {
const RealDate: DateConstructor = Date;
@@ -60,10 +60,17 @@ describe('components/AboutBuildModal', () => {
});
test('should match snapshot for enterprise edition', () => {
const wrapper = shallowAboutBuildModal({config, license});
expect(wrapper.find('#versionString').text()).toBe('\u00a03.6.2');
expect(wrapper.find('#dbversionString').text()).toBe('\u00a077');
expect(wrapper).toMatchSnapshot();
renderAboutBuildModal({config, license});
expect(screen.getByTestId('aboutModalVersion')).toHaveTextContent('Mattermost Version: 3.6.2');
expect(screen.getByTestId('aboutModalDBVersionString')).toHaveTextContent('Database Schema Version: 77');
expect(screen.getByText('Mattermost Enterprise Edition')).toBeInTheDocument();
expect(screen.getByText('Modern communication from behind your firewall.')).toBeInTheDocument();
expect(screen.getByRole('link', {name: 'mattermost.com'})).toHaveAttribute('href', 'https://mattermost.com/?utm_source=mattermost&utm_medium=in-product&utm_content=about_build_modal&uid=&sid=');
expect(screen.getByText('EE Build Hash: 0123456789abcdef', {exact: false})).toBeInTheDocument();
expect(screen.getByRole('link', {name: 'server'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-server/blob/master/NOTICE.txt');
expect(screen.getByRole('link', {name: 'desktop'})).toHaveAttribute('href', 'https://github.com/mattermost/desktop/blob/master/NOTICE.txt');
expect(screen.getByRole('link', {name: 'mobile'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-mobile/blob/master/NOTICE.txt');
});
test('should match snapshot for team edition', () => {
@@ -73,19 +80,25 @@ describe('components/AboutBuildModal', () => {
BuildHashEnterprise: '',
};
const wrapper = shallowAboutBuildModal({config: teamConfig, license: {}});
expect(wrapper.find('#versionString').text()).toBe('\u00a03.6.2');
expect(wrapper.find('#dbversionString').text()).toBe('\u00a077');
expect(wrapper).toMatchSnapshot();
renderAboutBuildModal({config: teamConfig, license: {}});
expect(screen.getByTestId('aboutModalVersion')).toHaveTextContent('Mattermost Version: 3.6.2');
expect(screen.getByTestId('aboutModalDBVersionString')).toHaveTextContent('Database Schema Version: 77');
expect(screen.getByText('Mattermost Team Edition')).toBeInTheDocument();
expect(screen.getByText('All your team communication in one place, instantly searchable and accessible anywhere.')).toBeInTheDocument();
expect(screen.getByRole('link', {name: 'mattermost.com/community/'})).toHaveAttribute('href', 'https://mattermost.com/community/?utm_source=mattermost&utm_medium=in-product&utm_content=about_build_modal&uid=&sid=');
expect(screen.queryByText('EE Build Hash: 0123456789abcdef')).not.toBeInTheDocument();
expect(screen.getByRole('link', {name: 'server'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-server/blob/master/NOTICE.txt');
expect(screen.getByRole('link', {name: 'desktop'})).toHaveAttribute('href', 'https://github.com/mattermost/desktop/blob/master/NOTICE.txt');
expect(screen.getByRole('link', {name: 'mobile'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-mobile/blob/master/NOTICE.txt');
});
test('should match snapshot for cloud edition', () => {
if (license !== null) {
license.Cloud = 'true';
}
const store = mockStore();
const wrapper = shallow(
renderWithIntl(
<Provider store={store}>
<AboutBuildModalCloud
config={config}
@@ -96,7 +109,14 @@ describe('components/AboutBuildModal', () => {
/>
</Provider>,
);
expect(wrapper).toMatchSnapshot();
expect(screen.getByText('Mattermost Cloud')).toBeInTheDocument();
expect(screen.getByText('High trust messaging for the enterprise')).toBeInTheDocument();
expect(screen.getByText('0123456789abcdef', {exact: false})).toBeInTheDocument();
expect(screen.getByRole('link', {name: 'server'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-server/blob/master/NOTICE.txt');
expect(screen.getByRole('link', {name: 'desktop'})).toHaveAttribute('href', 'https://github.com/mattermost/desktop/blob/master/NOTICE.txt');
expect(screen.getByRole('link', {name: 'mobile'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-mobile/blob/master/NOTICE.txt');
});
test('should show dev if this is a dev build', () => {
@@ -109,10 +129,18 @@ describe('components/AboutBuildModal', () => {
BuildNumber: 'dev',
};
const wrapper = shallowAboutBuildModal({config: sameBuildConfig, license: {}});
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('#versionString').text()).toBe('\u00a0dev');
expect(wrapper.find('#dbversionString').text()).toBe('\u00a077');
renderAboutBuildModal({config: sameBuildConfig, license: {}});
expect(screen.getByTestId('aboutModalVersion')).toHaveTextContent('Mattermost Version: dev');
expect(screen.getByTestId('aboutModalDBVersionString')).toHaveTextContent('Database Schema Version: 77');
expect(screen.getByText('Mattermost Team Edition')).toBeInTheDocument();
expect(screen.getByText('All your team communication in one place, instantly searchable and accessible anywhere.')).toBeInTheDocument();
expect(screen.getByRole('link', {name: 'mattermost.com/community/'})).toHaveAttribute('href', 'https://mattermost.com/community/?utm_source=mattermost&utm_medium=in-product&utm_content=about_build_modal&uid=&sid=');
expect(screen.queryByText('EE Build Hash: 0123456789abcdef')).not.toBeInTheDocument();
expect(screen.getByRole('link', {name: 'server'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-server/blob/master/NOTICE.txt');
expect(screen.getByRole('link', {name: 'desktop'})).toHaveAttribute('href', 'https://github.com/mattermost/desktop/blob/master/NOTICE.txt');
expect(screen.getByRole('link', {name: 'mobile'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-mobile/blob/master/NOTICE.txt');
});
test('should show ci if a ci build', () => {
@@ -125,11 +153,19 @@ describe('components/AboutBuildModal', () => {
BuildNumber: '123',
};
const wrapper = shallowAboutBuildModal({config: differentBuildConfig, license: {}});
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('#versionString').text()).toBe('\u00a0ci');
expect(wrapper.find('#dbversionString').text()).toBe('\u00a077');
expect(wrapper.find('#buildnumberString').text()).toBe('\u00a0123');
renderAboutBuildModal({config: differentBuildConfig, license: {}});
expect(screen.getByTestId('aboutModalVersion')).toHaveTextContent('Mattermost Version: ci');
expect(screen.getByTestId('aboutModalDBVersionString')).toHaveTextContent('Database Schema Version: 77');
expect(screen.getByTestId('aboutModalBuildNumber')).toHaveTextContent('Build Number: 123');
expect(screen.getByText('Mattermost Team Edition')).toBeInTheDocument();
expect(screen.getByText('All your team communication in one place, instantly searchable and accessible anywhere.')).toBeInTheDocument();
expect(screen.getByRole('link', {name: 'mattermost.com/community/'})).toHaveAttribute('href', 'https://mattermost.com/community/?utm_source=mattermost&utm_medium=in-product&utm_content=about_build_modal&uid=&sid=');
expect(screen.queryByText('EE Build Hash: 0123456789abcdef')).not.toBeInTheDocument();
expect(screen.getByRole('link', {name: 'server'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-server/blob/master/NOTICE.txt');
expect(screen.getByRole('link', {name: 'desktop'})).toHaveAttribute('href', 'https://github.com/mattermost/desktop/blob/master/NOTICE.txt');
expect(screen.getByRole('link', {name: 'mobile'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-mobile/blob/master/NOTICE.txt');
});
test('should call onExited callback when the modal is hidden', () => {
@@ -148,7 +184,7 @@ describe('components/AboutBuildModal', () => {
},
});
const wrapper = mountWithIntl(
renderWithIntl(
<Provider store={store}>
<AboutBuildModal
config={config}
@@ -158,7 +194,7 @@ describe('components/AboutBuildModal', () => {
</Provider>,
);
wrapper.find(Modal).first().props().onExited?.(document.createElement('div'));
userEvent.click(screen.getByText('Close'));
expect(onExited).toHaveBeenCalledTimes(1);
});
@@ -176,7 +212,7 @@ describe('components/AboutBuildModal', () => {
},
},
});
const wrapper = mountWithIntl(
renderWithIntl(
<Provider store={store}>
<AboutBuildModal
config={config}
@@ -186,24 +222,15 @@ describe('components/AboutBuildModal', () => {
</Provider>,
);
expect(
wrapper.find(AboutBuildModal).find('a#tosLink').props().href,
).toBe(
AboutLinks.TERMS_OF_SERVICE +
'?utm_source=mattermost&utm_medium=in-product&utm_content=about_build_modal&uid=currentUserId&sid=',
);
expect(
wrapper.find(AboutBuildModal).find('a#privacyLink').props().href,
).toBe(
AboutLinks.PRIVACY_POLICY +
'?utm_source=mattermost&utm_medium=in-product&utm_content=about_build_modal&uid=currentUserId&sid=',
);
expect(screen.getByRole('link', {name: 'Terms of Use'})).toHaveAttribute('href', `${AboutLinks.TERMS_OF_SERVICE}?utm_source=mattermost&utm_medium=in-product&utm_content=about_build_modal&uid=currentUserId&sid=`);
expect(wrapper.find(AboutBuildModal).find('a#tosLink').props().href).not.toBe(config?.TermsOfServiceLink);
expect(wrapper.find(AboutBuildModal).find('a#privacyLink').props().href).not.toBe(config?.PrivacyPolicyLink);
expect(screen.getByRole('link', {name: 'Privacy Policy'})).toHaveAttribute('href', `${AboutLinks.PRIVACY_POLICY}?utm_source=mattermost&utm_medium=in-product&utm_content=about_build_modal&uid=currentUserId&sid=`);
expect(screen.getByRole('link', {name: 'Terms of Use'})).not.toHaveAttribute('href', config?.TermsOfServiceLink);
expect(screen.getByRole('link', {name: 'Privacy Policy'})).not.toHaveAttribute('href', config?.PrivacyPolicyLink);
});
function shallowAboutBuildModal(props = {}) {
function renderAboutBuildModal(props = {}) {
const onExited = jest.fn();
const show = true;
@@ -215,6 +242,6 @@ describe('components/AboutBuildModal', () => {
...props,
};
return shallow(<AboutBuildModal {...allProps}/>);
return renderWithIntl(<Provider store={store}><AboutBuildModal {...allProps}/></Provider>);
}
});

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

@@ -49,6 +49,7 @@ export default class AboutBuildModal extends React.PureComponent<Props, State> {
doHide = () => {
this.setState({show: false});
this.props.onExited();
};
render() {
@@ -172,7 +173,7 @@ export default class AboutBuildModal extends React.PureComponent<Props, State> {
// Only show build number if it's a number (so only builds from Jenkins)
let buildnumber: JSX.Element | null = (
<div>
<div data-testid='aboutModalBuildNumber'>
<FormattedMessage
id='about.buildnumber'
defaultMessage='Build Number:'
@@ -227,7 +228,7 @@ export default class AboutBuildModal extends React.PureComponent<Props, State> {
{subTitle}
</p>
<div className='form-group less'>
<div>
<div data-testid='aboutModalVersion'>
<FormattedMessage
id='about.version'
defaultMessage='Mattermost Version:'
@@ -236,7 +237,7 @@ export default class AboutBuildModal extends React.PureComponent<Props, State> {
{'\u00a0' + mmversion}
</span>
</div>
<div>
<div data-testid='aboutModalDBVersionString'>
<FormattedMessage
id='about.dbversion'
defaultMessage='Database Schema Version:'

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

@@ -6,8 +6,7 @@ import {Link, useRouteMatch, useLocation, matchPath} from 'react-router-dom';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import classNames from 'classnames';
import Icon from '@mattermost/compass-components/foundations/icon'; // eslint-disable-line no-restricted-imports
import {ChartLineIcon} from '@mattermost/compass-icons/components';
import {insightsAreEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getIsRhsOpen, getRhsState} from 'selectors/rhs';
@@ -62,10 +61,7 @@ const ActivityAndInsightsLink = () => {
tabIndex={0}
>
<span className='icon'>
<Icon
size={12}
glyph={'chart-line'}
/>
<ChartLineIcon size={14}/>
</span>
<div className='SidebarChannelLinkLabel_wrapper'>
<span className='SidebarChannelLinkLabel sidebar-item__name'>

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

@@ -222,7 +222,7 @@ exports[`components/activity_and_insights/insights/top_channels should match sna
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Top Channels"
id="analytics.system.totalBotPosts"
id="analytics.system.topChannels"
/>
}
>
@@ -237,7 +237,7 @@ exports[`components/activity_and_insights/insights/top_channels should match sna
>
<FormattedMessage
defaultMessage="Top Channels"
id="analytics.system.totalBotPosts"
id="analytics.system.topChannels"
>
<span>
Top Channels

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

@@ -115,7 +115,7 @@ const TopChannelsLineChart = ({topChannels, timeFrame, channelLineChartData, tim
<LineChart
title={
<FormattedMessage
id='analytics.system.totalBotPosts'
id='analytics.system.topChannels'
defaultMessage='Top Channels'
/>
}

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

@@ -353,6 +353,36 @@ exports[`components/DatabaseSettings should match snapshot 1`] = `
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
>
<MemoizedFormattedMessage
defaultMessage="Active Search Backend:"
id="admin.database.search_backend.title"
/>
</label>
<div
className="col-sm-8"
>
<input
className="form-control"
disabled={true}
type="text"
value=""
/>
<div
className="help-text"
>
<MemoizedFormattedMessage
defaultMessage="Shows the currently active backend used for search. Values can be none, database, elasticsearch, bleve etc."
id="admin.database.search_backend.help_text"
/>
</div>
</div>
</div>
</SettingsGroup>
<div
className="admin-console-save"

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

@@ -2869,7 +2869,7 @@ const AdminDefinition = {
label: t('admin.customization.enableSVGsTitle'),
label_default: 'Enable SVGs:',
help_text: t('admin.customization.enableSVGsDesc'),
help_text_default: 'Enable previews for SVG file attachments and allow them to appear in messages.',
help_text_default: 'Enable previews for SVG file attachments and allow them to appear in messages.\n\nEnabling SVGs is not recommended in environments where not all users are trusted.',
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)),
},
{
@@ -2878,7 +2878,7 @@ const AdminDefinition = {
label: t('admin.customization.enableLatexTitle'),
label_default: 'Enable Latex Rendering:',
help_text: t('admin.customization.enableLatexDesc'),
help_text_default: 'Enable rendering of Latex in code blocks. If false, Latex code will be highlighted only.',
help_text_default: 'Enable rendering of Latex in code blocks. If false, Latex code will be highlighted only.\n\nEnabling Latex is not recommended in environments where not all users are trusted.',
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)),
},
{

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

@@ -1,37 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/admin_console/billing/billing_history should match snapshot 1`] = `
<ContextProvider
value={
Object {
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"subscription": Subscription {
"handleChangeWrapper": [Function],
"listeners": Object {
"notify": [Function],
},
"onStateChange": [Function],
"parentSub": undefined,
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"unsubscribe": null,
},
}
}
>
<BillingHistory />
</ContextProvider>
`;

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

@@ -5,11 +5,9 @@ import React from 'react';
import {Provider} from 'react-redux';
import {shallow} from 'enzyme';
import {screen} from '@testing-library/react';
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import {renderWithIntl, renderWithIntlAndStore} from 'tests/react_testing_utils';
import mockStore from 'tests/test_store';
import {CloudLinks, HostedCustomerLinks} from 'utils/constants';
@@ -35,7 +33,7 @@ const invoiceA = {
quantity: 1,
price_per_unit: 1000,
description:
'1 × Cloud Professional (at $10.00 / month)',
'1 × Cloud Professional (at $10.00 / month)',
type: 'onpremise',
metadata: {},
},
@@ -59,7 +57,7 @@ const invoiceB = {
quantity: 1,
price_per_unit: 1000,
description:
'Trial period for Cloud Professional',
'Trial period for Cloud Professional',
type: 'onpremise',
metadata: {},
},
@@ -98,13 +96,21 @@ describe('components/admin_console/billing/billing_history', () => {
const store = mockStore(state);
test('should match snapshot', () => {
const wrapper = shallow(
test('should match the default state of the component with given props', () => {
renderWithIntl(
<Provider store={store}>
<BillingHistory/>
</Provider>,
);
expect(wrapper).toMatchSnapshot();
expect(screen.queryByText('Billing History')).toBeInTheDocument();
expect(screen.queryByText('Transactions')).toBeInTheDocument();
expect(screen.queryByText('All of your invoices will be shown here')).toBeInTheDocument();
expect(screen.getByTestId(invoiceA.number)).toHaveTextContent((invoiceA.total / 100.0).toString());
expect(screen.getByTestId(invoiceB.number)).toHaveTextContent((invoiceB.total / 100.0).toString());
expect(screen.getByTestId(invoiceA.id)).toHaveTextContent('Pending');
expect(screen.getByTestId(invoiceB.id)).toHaveTextContent('Paid');
});
test('Billing history section shows template when no invoices have been emitted yet', () => {
@@ -113,42 +119,54 @@ describe('components/admin_console/billing/billing_history', () => {
entities: {...state.entities, cloud: {invoices: {}, errors: {}}},
};
const storeNoBillingHistory = mockStore(noBillingHistoryState);
const wrapper = mountWithIntl(
renderWithIntl(
<Provider store={storeNoBillingHistory}>
<BillingHistory/>
</Provider>,
);
const legend = wrapper.find(
'.BillingHistory__cardHeaderText-bottom span',
);
expect(legend.text()).toBe(NO_INVOICES_LEGEND);
expect(screen.queryByText('Date')).not.toBeInTheDocument();
expect(screen.queryByText('Description')).not.toBeInTheDocument();
expect(screen.queryByText('Total')).not.toBeInTheDocument();
expect(screen.queryByText('Status')).not.toBeInTheDocument();
expect(screen.queryByTestId(invoiceA.number)).not.toBeInTheDocument();
expect(screen.queryByTestId(invoiceB.number)).not.toBeInTheDocument();
expect(screen.queryByTestId(invoiceA.id)).not.toBeInTheDocument();
expect(screen.queryByTestId(invoiceB.id)).not.toBeInTheDocument();
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://docs.mattermost.com/cloud/cloud-billing/cloud-billing.html?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=billing_history&uid=current_user_id&sid=');
expect(screen.getByRole('link')).toHaveTextContent('See how billing works');
expect(screen.getByTestId('no-invoices')).toHaveTextContent(NO_INVOICES_LEGEND);
});
test('Billing history section shows two invoices to download', () => {
const wrapper = mountWithIntl(
renderWithIntl(
<Provider store={store}>
<BillingHistory/>
</Provider>,
);
const invoiceTableRows = wrapper.find('table.BillingHistory__table tr.BillingHistory__table-row');
expect(screen.queryByText('Date')).toBeInTheDocument();
expect(screen.queryByText('Description')).toBeInTheDocument();
expect(screen.queryByText('Total')).toBeInTheDocument();
expect(screen.queryByText('Status')).toBeInTheDocument();
expect(invoiceTableRows.length).toBe(2);
expect(screen.getAllByTestId('billingHistoryTableRow')).toHaveLength(2);
});
test('Billing history section download button has the target property set as _self so it works well in desktop app', () => {
const wrapper = mountWithIntl(
renderWithIntl(
<Provider store={store}>
<BillingHistory/>
</Provider>,
);
const invoiceTableRow = wrapper.find('table.BillingHistory__table tr.BillingHistory__table-row').at(0);
const downloadLink = invoiceTableRow.find('td.BillingHistory__table-invoice a');
expect(downloadLink.prop('target')).toBe('_self');
expect(screen.getByTestId(`billingHistoryLink-${invoiceA.id}`)).toHaveAttribute('target', '_self');
expect(screen.getByTestId(`billingHistoryLink-${invoiceB.id}`)).toHaveAttribute('target', '_self');
expect(screen.getByTestId(`billingHistoryLink-${invoiceA.id}`)).toHaveAttribute('href', '/api/v4/cloud/subscription/invoices/in_1KNb3DI67GP2qpb4ueaJYBt8/pdf');
expect(screen.getByTestId(`billingHistoryLink-${invoiceB.id}`)).toHaveAttribute('href', '/api/v4/cloud/subscription/invoices/in_1KIWNTI67GP2qpb4KjGj1KAy/pdf');
});
});
@@ -191,30 +209,42 @@ describe('BillingHistory -- self-hosted', () => {
entities: {...state.entities, hostedCustomer: {invoices: {invoices: {}, invoicesLoaded: true}, errors: {}}},
};
const storeNoBillingHistory = mockStore(noBillingHistoryState);
const wrapper = mountWithIntl(
renderWithIntl(
<Provider store={storeNoBillingHistory}>
<BillingHistory/>
</Provider>,
);
const legend = wrapper.find(
'.BillingHistory__cardHeaderText-bottom span',
);
expect(legend.text()).toBe(NO_INVOICES_LEGEND);
expect(screen.queryByText('Date')).not.toBeInTheDocument();
expect(screen.queryByText('Description')).not.toBeInTheDocument();
expect(screen.queryByText('Total')).not.toBeInTheDocument();
expect(screen.queryByText('Status')).not.toBeInTheDocument();
expect(screen.queryByTestId(invoiceA.number)).not.toBeInTheDocument();
expect(screen.queryByTestId(invoiceB.number)).not.toBeInTheDocument();
expect(screen.queryByTestId(invoiceA.id)).not.toBeInTheDocument();
expect(screen.queryByTestId(invoiceB.id)).not.toBeInTheDocument();
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://docs.mattermost.com/manage/self-hosted-billing.html?utm_source=mattermost&utm_medium=in-product&utm_content=billing_history&uid=current_user_id&sid=');
expect(screen.getByRole('link')).toHaveTextContent('See how billing works');
expect(screen.getByTestId('no-invoices')).toHaveTextContent(NO_INVOICES_LEGEND);
});
test('Billing history section shows two invoices to download', () => {
const store = mockStore(state);
const wrapper = mountWithIntl(
renderWithIntl(
<Provider store={store}>
<BillingHistory/>
</Provider>,
);
const invoiceTableRows = wrapper.find('table.BillingHistory__table tr.BillingHistory__table-row');
expect(invoiceTableRows.length).toBe(2);
expect(screen.queryByText('Date')).toBeInTheDocument();
expect(screen.queryByText('Description')).toBeInTheDocument();
expect(screen.queryByText('Total')).toBeInTheDocument();
expect(screen.queryByText('Status')).toBeInTheDocument();
expect(screen.getAllByTestId('billingHistoryTableRow')).toHaveLength(2);
});
});

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

@@ -39,6 +39,7 @@ export const NoBillingHistorySection = (props: NoBillingHistorySectionProps) =>
/>
</div>
<ExternalLink
data-testid='billingHistoryLink'
location='billing_history'
href={props.selfHosted ? HostedCustomerLinks.SELF_HOSTED_BILLING : CloudLinks.BILLING_DOCS}
className='BillingHistory__noHistory-link'
@@ -84,7 +85,10 @@ const BillingHistory = () => {
defaultMessage='Transactions'
/>
</div>
<div className='BillingHistory__cardHeaderText-bottom'>
<div
data-testid='no-invoices'
className='BillingHistory__cardHeaderText-bottom'
>
<FormattedMessage
id='admin.billing.history.allPaymentsShowHere'
defaultMessage='All of your invoices will be shown here'

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

@@ -176,7 +176,7 @@ export default function BillingHistoryTable({invoices}: BillingHistoryTableProps
);
}}
>
<td>
<td data-testid='billingHistoryTableRow'>
<FormattedDate
value={new Date(invoice.period_start)}
month='2-digit'
@@ -191,7 +191,10 @@ export default function BillingHistoryTable({invoices}: BillingHistoryTableProps
<InvoiceUserCount invoice={invoice}/>
</div>
</td>
<td className='BillingHistory__table-total'>
<td
data-testid={invoice.number}
className='BillingHistory__table-total'
>
<FormattedNumber
value={invoice.total / 100.0}
// eslint-disable-next-line react/style-prop-object
@@ -199,9 +202,10 @@ export default function BillingHistoryTable({invoices}: BillingHistoryTableProps
currency='USD'
/>
</td>
<td>{getPaymentStatus(invoice.status)}</td>
<td data-testid={invoice.id}>{getPaymentStatus(invoice.status)}</td>
<td className='BillingHistory__table-invoice'>
<a
data-testid={`billingHistoryLink-${invoice.id}`}
target='_self'
rel='noopener noreferrer'
onClick={(e) => e.stopPropagation()}

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

@@ -48,6 +48,7 @@ import {
import LimitReachedBanner from './limit_reached_banner';
import CancelSubscription from './cancel_subscription';
import {ToYearlyNudgeBanner} from './to_yearly_nudge_banner';
import {ToPaidNudgeBanner} from './to_paid_plan_nudge_banner';
import './billing_subscriptions.scss';
@@ -136,6 +137,7 @@ const BillingSubscriptions = () => {
/>
{shouldShowPaymentFailedBanner() && paymentFailedBanner()}
{<ToYearlyNudgeBanner/>}
{<ToPaidNudgeBanner/>}
{showCreditCardBanner &&
isCardExpired &&
creditCardExpiredBanner(setShowCreditCardBanner)}

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

@@ -0,0 +1,28 @@
@import 'utils/mixins';
.ToPaidNudgeBanner {
&__actions {
padding-top: 12px;
}
&__primary {
font-size: 12px;
@include primary-button;
&:hover {
color: var(--button-color);
}
}
&__secondary {
margin-left: 4px;
font-size: 12px;
@include tertiary-button;
&:hover {
color: var(--button-bg);
}
}
}

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

@@ -0,0 +1,239 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {screen} from '@testing-library/react';
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
import {CloudProducts} from 'utils/constants';
import {ToPaidNudgeBanner, ToPaidPlanBannerDismissable} from './to_paid_plan_nudge_banner';
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
general: {
config: {
CWSURL: '',
FeatureFlagDeprecateCloudFree: 'true',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_user'},
},
},
preferences: {
myPreferences: {},
},
cloud: {},
},
};
describe('ToPaidPlanBannerDismissable', () => {
test('should only show for admins on cloud free', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_starter',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_starter: {
id: 'prod_starter',
sku: CloudProducts.STARTER,
},
},
};
renderWithIntlAndStore(<ToPaidPlanBannerDismissable/>, state);
screen.getByTestId('cloud-free-deprecation-announcement-bar');
});
test('should NOT show for NON admins', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_user'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_starter',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_starter: {
id: 'prod_starter',
sku: CloudProducts.STARTER,
},
},
};
renderWithIntlAndStore(<ToPaidPlanBannerDismissable/>, state);
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins on cloud pro', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_pro',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_pro: {
id: 'prod_pro',
sku: CloudProducts.PROFESSIONAL,
},
},
};
renderWithIntlAndStore(<ToPaidPlanBannerDismissable/>, state);
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins on cloud enterprise', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_enterprise',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_enterprise: {
id: 'prod_enterprise',
sku: CloudProducts.ENTERPRISE,
},
},
};
renderWithIntlAndStore(<ToPaidPlanBannerDismissable/>, state);
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins when banner was dismissed in preferences', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.preferences = {
myPreferences: {
'to_paid_plan_nudge--nudge_to_paid_plan_snoozed': {
category: 'to_paid_plan_nudge',
name: 'nudge_to_paid_plan_snoozed',
value: '{"range": 0, "show": false}',
},
},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_starter',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_starter: {
id: 'prod_starter',
sku: CloudProducts.STARTER,
},
},
};
renderWithIntlAndStore(<ToPaidPlanBannerDismissable/>, state);
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
});
describe('ToPaidNudgeBanner', () => {
test('should show only for cloud free', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_starter',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_starter: {
id: 'prod_starter',
sku: CloudProducts.STARTER,
},
},
};
renderWithIntlAndStore(<ToPaidNudgeBanner/>, state);
screen.getByTestId('cloud-free-deprecation-alert-banner');
});
test('should NOT show for cloud professional', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_pro',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_pro: {
id: 'prod_pro',
sku: CloudProducts.PROFESSIONAL,
},
},
};
renderWithIntlAndStore(<ToPaidNudgeBanner/>, state);
expect(() => screen.getByTestId('cloud-free-deprecation-alert-banner')).toThrow();
});
test('should NOT show for cloud enterprise', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_ent',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_ent: {
id: 'prod_ent',
sku: CloudProducts.ENTERPRISE,
},
},
};
renderWithIntlAndStore(<ToPaidNudgeBanner/>, state);
expect(() => screen.getByTestId('cloud-free-deprecation-alert-banner')).toThrow();
});
});

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

@@ -0,0 +1,246 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {useIntl, FormattedMessage} from 'react-intl';
import moment from 'moment';
import AlertBanner from 'components/alert_banner';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import AnnouncementBar from 'components/announcement_bar/default_announcement_bar';
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';
import {deprecateCloudFree, get as getPreference} from 'mattermost-redux/selectors/entities/preferences';
import {AnnouncementBarTypes, CloudBanners, CloudProducts, Preferences} from 'utils/constants';
import {t} from 'utils/i18n';
import {GlobalState} from '@mattermost/types/store';
import './to_paid_plan_nudge_banner.scss';
enum DismissShowRange {
GreaterThanEqual90 = '>=90',
BetweenNinetyAnd60 = '89-61',
SixtyTo31 = '60-31',
ThirtyTo11 = '30-11',
TenTo1 = '10-1',
Zero = '0'
}
const cloudFreeCloseMoment = '20230727';
interface ToPaidPlanDismissPreference {
// range represents the range for the days to the deprecation of cloud free e.g. in 30 to 10 days to deprecate cloud free
// Incase of dismissing the banner, range represents the time (days) period when this banner was dismissed.
// This is important because in case the banner was dismissed for a certain period, it helps us know that we should not show it again for that period.
range: DismissShowRange;
show: boolean;
}
export const ToPaidPlanBannerDismissable = () => {
const dispatch = useDispatch();
const openPricingModal = useOpenPricingModal();
const currentUser = useSelector(getCurrentUser);
const isAdmin = useSelector(isCurrentUserSystemAdmin);
const product = useSelector(selectSubscriptionProduct);
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
const currentProductStarter = product?.sku === CloudProducts.STARTER;
const now = moment(Date.now());
const cloudFreeEndDate = moment(cloudFreeCloseMoment, 'YYYYMMDD');
const daysToCloudFreeEnd = cloudFreeEndDate.diff(now, 'days');
const snoozePreferenceVal = useSelector((state: GlobalState) => getPreference(state, Preferences.TO_PAID_PLAN_NUDGE, CloudBanners.NUDGE_TO_PAID_PLAN_SNOOZED, '{"range": 0, "show": true}'));
const snoozeInfo = JSON.parse(snoozePreferenceVal) as ToPaidPlanDismissPreference;
const show = snoozeInfo.show;
const snoozedForRange = (range: DismissShowRange) => {
return snoozeInfo.range === range;
};
useEffect(() => {
if (!snoozeInfo.show) {
if (daysToCloudFreeEnd >= 90 && !snoozedForRange(DismissShowRange.GreaterThanEqual90)) {
showBanner(true);
}
if (daysToCloudFreeEnd < 90 && daysToCloudFreeEnd > 60 && !snoozedForRange(DismissShowRange.BetweenNinetyAnd60)) {
showBanner(true);
}
if (daysToCloudFreeEnd <= 60 && daysToCloudFreeEnd > 30 && !snoozedForRange(DismissShowRange.SixtyTo31)) {
showBanner(true);
}
if (daysToCloudFreeEnd <= 30 && daysToCloudFreeEnd > 10 && !snoozedForRange(DismissShowRange.ThirtyTo11)) {
showBanner(true);
}
if (daysToCloudFreeEnd <= 10) {
showBanner(true);
}
}
}, []);
const showBanner = (show = false) => {
let dRange = DismissShowRange.Zero;
if (daysToCloudFreeEnd >= 90) {
dRange = DismissShowRange.GreaterThanEqual90;
}
if (daysToCloudFreeEnd < 90 && daysToCloudFreeEnd > 60) {
dRange = DismissShowRange.BetweenNinetyAnd60;
}
if (daysToCloudFreeEnd <= 60 && daysToCloudFreeEnd > 30) {
dRange = DismissShowRange.SixtyTo31;
}
if (daysToCloudFreeEnd <= 30 && daysToCloudFreeEnd > 10) {
dRange = DismissShowRange.ThirtyTo11;
}
// ideally this case should not happen because snooze button is not shown when TenTo1 days are remaining
if (daysToCloudFreeEnd <= 10 && daysToCloudFreeEnd > 0) {
dRange = DismissShowRange.TenTo1;
}
const snoozeInfo: ToPaidPlanDismissPreference = {
range: dRange,
show,
};
dispatch(savePreferences(currentUser.id, [{
category: Preferences.TO_PAID_PLAN_NUDGE,
name: CloudBanners.NUDGE_TO_PAID_PLAN_SNOOZED,
user_id: currentUser.id,
value: JSON.stringify(snoozeInfo),
}]));
};
if (!cloudFreeDeprecated) {
return null;
}
if (!show) {
return null;
}
if (!isAdmin) {
return null;
}
if (!currentProductStarter) {
return null;
}
let message = {
id: 'cloud_billing.nudge_to_paid.announcement_bar',
defaultMessage: 'Cloud Free will be deprecated on {date}. To keep your workspace, upgrade to a paid plan',
values: {
date: moment(cloudFreeCloseMoment, 'YYYYMMDD').format('MMMM DD, YYYY'),
},
};
if (daysToCloudFreeEnd < 0) {
message = {
id: 'cloud_billing.nudge_to_paid.announcement_bar_deprecated',
defaultMessage: 'Cloud Free was deprecated. To keep your workspace, upgrade to a paid plan',
} as any;
}
const announcementType = (daysToCloudFreeEnd <= 10) ? AnnouncementBarTypes.CRITICAL : AnnouncementBarTypes.ANNOUNCEMENT;
return (
<AnnouncementBar
id='cloud-free-deprecation-announcement-bar'
type={announcementType}
showCloseButton={daysToCloudFreeEnd > 10}
onButtonClick={openPricingModal}
modalButtonText={t('cloud_billing.nudge_to_paid.view_plans')}
modalButtonDefaultText='View plans'
message={<FormattedMessage {...message}/>}
showLinkAsButton={true}
handleClose={showBanner}
/>
);
};
export const ToPaidNudgeBanner = () => {
const {formatMessage} = useIntl();
const [openSalesLink] = useOpenSalesLink();
const openPurchaseModal = useOpenCloudPurchaseModal({});
const product = useSelector(selectSubscriptionProduct);
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
const currentProductStarter = product?.sku === CloudProducts.STARTER;
if (!cloudFreeDeprecated) {
return null;
}
if (!currentProductStarter) {
return null;
}
const now = moment(Date.now());
const cloudFreeEndDate = moment(cloudFreeCloseMoment, 'YYYYMMDD');
const daysToCloudFreeEnd = cloudFreeEndDate.diff(now, 'days');
const title = (
<FormattedMessage
id='cloud_billing.nudge_to_paid.title'
defaultMessage='Upgrade to paid plan to keep your workspace'
/>
);
const description = (
<FormattedMessage
id='cloud_billing.nudge_to_paid.description'
defaultMessage='Cloud Free will be deprecated in {days} days. Upgrade to a paid plan or contact sales.'
values={{days: daysToCloudFreeEnd < 0 ? 0 : daysToCloudFreeEnd}}
/>
);
const viewPlansAction = (
<button
onClick={() => openPurchaseModal({trackingLocation: 'to_paid_plan_nudge_banner'})}
className='btn ToPaidNudgeBanner__primary'
>
{formatMessage({id: 'cloud_billing.nudge_to_paid.learn_more', defaultMessage: 'Upgrade'})}
</button>
);
const contactSalesAction = (
<button
onClick={openSalesLink}
className='btn ToPaidNudgeBanner__secondary'
>
{formatMessage({id: 'cloud_billing.nudge_to_paid.contact_sales', defaultMessage: 'Contact sales'})}
</button>
);
const bannerMode = (daysToCloudFreeEnd <= 10) ? 'danger' : 'info';
return (
<AlertBanner
id='cloud-free-deprecation-alert-banner'
mode={bannerMode}
title={title}
message={description}
className='ToYearlyNudgeBanner'
actionButtonLeft={viewPlansAction}
actionButtonRight={contactSalesAction}
/>
);
};

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

@@ -2,11 +2,9 @@
// See LICENSE.txt for license information.
import React from 'react';
import {screen} from '@testing-library/react';
import {Provider} from 'react-redux';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
import {CloudProducts, RecurringIntervals} from 'utils/constants';
import {ToYearlyNudgeBanner, ToYearlyNudgeBannerDismissable} from './to_yearly_nudge_banner';
@@ -42,7 +40,7 @@ const initialState = {
},
};
describe('components/admin_console/billing/ToYearlyNudgeBannerDismissable', () => {
describe('ToYearlyNudgeBannerDismissable', () => {
test('should show for admins cloud professional monthly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
@@ -63,14 +61,9 @@ describe('components/admin_console/billing/ToYearlyNudgeBannerDismissable', () =
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Provider store={store}>
<ToYearlyNudgeBannerDismissable/>
</Provider>,
);
renderWithIntlAndStore(<ToYearlyNudgeBannerDismissable/>, state);
expect(wrapper.find('AnnouncementBar').exists()).toBe(true);
screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar');
});
test('should NOT show for NON admins', () => {
@@ -93,14 +86,9 @@ describe('components/admin_console/billing/ToYearlyNudgeBannerDismissable', () =
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Provider store={store}>
<ToYearlyNudgeBannerDismissable/>
</Provider>,
);
renderWithIntlAndStore(<ToYearlyNudgeBannerDismissable/>, state);
expect(wrapper.find('AnnouncementBar').exists()).toBe(false);
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins on cloud free', () => {
@@ -123,14 +111,9 @@ describe('components/admin_console/billing/ToYearlyNudgeBannerDismissable', () =
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Provider store={store}>
<ToYearlyNudgeBannerDismissable/>
</Provider>,
);
renderWithIntlAndStore(<ToYearlyNudgeBannerDismissable/>, state);
expect(wrapper.find('AnnouncementBar').exists()).toBe(false);
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins on cloud enterprise', () => {
@@ -153,14 +136,9 @@ describe('components/admin_console/billing/ToYearlyNudgeBannerDismissable', () =
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Provider store={store}>
<ToYearlyNudgeBannerDismissable/>
</Provider>,
);
renderWithIntlAndStore(<ToYearlyNudgeBannerDismissable/>, state);
expect(wrapper.find('AnnouncementBar').exists()).toBe(false);
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins on cloud pro annual', () => {
@@ -182,15 +160,9 @@ describe('components/admin_console/billing/ToYearlyNudgeBannerDismissable', () =
},
},
};
renderWithIntlAndStore(<ToYearlyNudgeBannerDismissable/>, state);
const store = mockStore(state);
const wrapper = mountWithIntl(
<Provider store={store}>
<ToYearlyNudgeBannerDismissable/>
</Provider>,
);
expect(wrapper.find('AnnouncementBar').exists()).toBe(false);
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins when banner was dismissed in preferences', () => {
@@ -200,10 +172,10 @@ describe('components/admin_console/billing/ToYearlyNudgeBannerDismissable', () =
};
state.entities.preferences = {
myPreferences: {
'cloud_yearly_nudge_banner--nudge_to_yearly_banner_dismissed': {
category: 'cloud_yearly_nudge_banner',
name: 'nudge_to_yearly_banner_dismissed',
value: 'true',
'to_cloud_yearly_plan_nudge--nudge_to_cloud_yearly_plan_snoozed': {
category: 'to_cloud_yearly_plan_nudge',
name: 'nudge_to_cloud_yearly_plan_snoozed',
value: '{"range": 0, "show": false}',
},
},
};
@@ -221,19 +193,13 @@ describe('components/admin_console/billing/ToYearlyNudgeBannerDismissable', () =
},
},
};
renderWithIntlAndStore(<ToYearlyNudgeBannerDismissable/>, state);
const store = mockStore(state);
const wrapper = mountWithIntl(
<Provider store={store}>
<ToYearlyNudgeBannerDismissable/>
</Provider>,
);
expect(wrapper.find('AnnouncementBar').exists()).toBe(false);
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
});
});
describe('components/admin_console/billing/ToYearlyNudgeBanner', () => {
describe('ToYearlyNudgeBanner', () => {
test('should show for cloud professional monthly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
@@ -251,14 +217,9 @@ describe('components/admin_console/billing/ToYearlyNudgeBanner', () => {
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Provider store={store}>
<ToYearlyNudgeBanner/>
</Provider>,
);
renderWithIntlAndStore(<ToYearlyNudgeBanner/>, state);
expect(wrapper.find('AlertBanner').exists()).toBe(true);
screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner');
});
test('should NOT show for non cloud professional monthly', () => {
@@ -278,13 +239,9 @@ describe('components/admin_console/billing/ToYearlyNudgeBanner', () => {
},
};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Provider store={store}>
<ToYearlyNudgeBanner/>
</Provider>,
);
renderWithIntlAndStore(<ToYearlyNudgeBanner/>, state);
expect(wrapper.find('AlertBanner').exists()).toBe(false);
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner')).toThrow();
});
});

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

@@ -1,9 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useEffect} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {useIntl, FormattedMessage} from 'react-intl';
import moment from 'moment';
import AlertBanner from 'components/alert_banner';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
@@ -22,12 +23,35 @@ import {GlobalState} from '@mattermost/types/store';
import './to_yearly_nudge_banner.scss';
enum DismissShowRange {
GreaterThanEqual90 = '>=90',
BetweenNinetyAnd60 = '89-61',
SixtyTo31 = '60-31',
ThirtyTo11 = '30-11',
TenTo1 = '10-1',
Zero = '0'
}
const cloudProMonthlyCloseMoment = '20230727';
interface ToYearlyPlanDismissPreference {
// range represents the range for the days to the deprecation of cloud free e.g. in 30 to 10 days to deprecate cloud free
// Incase of dismissing the banner, range represents the time (days) period when this banner was dismissed.
// This is important because in case the banner was dismissed for a certain period, it helps us know that we should not show it again for that period.
range: DismissShowRange;
show: boolean;
}
const ToYearlyNudgeBannerDismissable = () => {
const dispatch = useDispatch();
const openPurchaseModal = useOpenCloudPurchaseModal({});
const nudgeDismissed = useSelector((state: GlobalState) => getPreference(state, Preferences.CLOUD_YEARLY_NUDGE_BANNER, CloudBanners.NUDGE_TO_YEARLY_BANNER_DISMISSED)) === 'true';
const snoozePreferenceVal = useSelector((state: GlobalState) => getPreference(state, Preferences.TO_CLOUD_YEARLY_PLAN_NUDGE, CloudBanners.NUDGE_TO_CLOUD_YEARLY_PLAN_SNOOZED, '{"range": 0, "show": true}'));
const snoozeInfo = JSON.parse(snoozePreferenceVal) as ToYearlyPlanDismissPreference;
const show = snoozeInfo.show;
const currentUser = useSelector(getCurrentUser);
const isAdmin = useSelector(isCurrentUserSystemAdmin);
const product = useSelector(selectSubscriptionProduct);
@@ -35,16 +59,75 @@ const ToYearlyNudgeBannerDismissable = () => {
const currentProductIsMonthly = product?.recurring_interval === RecurringIntervals.MONTH;
const currentProductProMonthly = currentProductProfessional && currentProductIsMonthly;
const savedDismissedPref = () => {
const now = moment(Date.now());
const proMonthlyEndDate = moment(cloudProMonthlyCloseMoment, 'YYYYMMDD');
const daysToProMonthlyEnd = proMonthlyEndDate.diff(now, 'days');
const snoozedForRange = (range: DismissShowRange) => {
return snoozeInfo.range === range;
};
useEffect(() => {
if (!snoozeInfo.show) {
if (daysToProMonthlyEnd >= 90 && !snoozedForRange(DismissShowRange.GreaterThanEqual90)) {
showBanner(true);
}
if (daysToProMonthlyEnd < 90 && daysToProMonthlyEnd > 60 && !snoozedForRange(DismissShowRange.BetweenNinetyAnd60)) {
showBanner(true);
}
if (daysToProMonthlyEnd <= 60 && daysToProMonthlyEnd > 30 && !snoozedForRange(DismissShowRange.SixtyTo31)) {
showBanner(true);
}
if (daysToProMonthlyEnd <= 30 && daysToProMonthlyEnd > 10 && !snoozedForRange(DismissShowRange.ThirtyTo11)) {
showBanner(true);
}
if (daysToProMonthlyEnd <= 10) {
showBanner(true);
}
}
}, []);
const showBanner = (show = false) => {
let dRange = DismissShowRange.Zero;
if (daysToProMonthlyEnd >= 90) {
dRange = DismissShowRange.GreaterThanEqual90;
}
if (daysToProMonthlyEnd < 90 && daysToProMonthlyEnd > 60) {
dRange = DismissShowRange.BetweenNinetyAnd60;
}
if (daysToProMonthlyEnd <= 60 && daysToProMonthlyEnd > 30) {
dRange = DismissShowRange.SixtyTo31;
}
if (daysToProMonthlyEnd <= 30 && daysToProMonthlyEnd > 10) {
dRange = DismissShowRange.ThirtyTo11;
}
// ideally this case should not happen because snooze button is not shown when TenTo1 days are remaining
if (daysToProMonthlyEnd <= 10 && daysToProMonthlyEnd > 0) {
dRange = DismissShowRange.TenTo1;
}
const snoozeInfo: ToYearlyPlanDismissPreference = {
range: dRange,
show,
};
dispatch(savePreferences(currentUser.id, [{
category: Preferences.CLOUD_YEARLY_NUDGE_BANNER,
name: CloudBanners.NUDGE_TO_YEARLY_BANNER_DISMISSED,
category: Preferences.TO_CLOUD_YEARLY_PLAN_NUDGE,
name: CloudBanners.NUDGE_TO_CLOUD_YEARLY_PLAN_SNOOZED,
user_id: currentUser.id,
value: 'true',
value: JSON.stringify(snoozeInfo),
}]));
};
if (nudgeDismissed) {
if (!show) {
return null;
}
@@ -56,21 +139,30 @@ const ToYearlyNudgeBannerDismissable = () => {
return null;
}
const message = {
id: 'cloud_billing.nudge_to_yearly.announcement_bar',
defaultMessage: 'Simplify your billing and switch to an annual plan today',
};
const message = (
<FormattedMessage
id='cloud_billing.nudge_to_yearly.announcement_bar'
defaultMessage='Monthly billing will be discontinued in {days} days . Switch to annual billing'
values={{
days: daysToProMonthlyEnd,
}}
/>
);
const announcementType = (daysToProMonthlyEnd <= 10) ? AnnouncementBarTypes.CRITICAL : AnnouncementBarTypes.ANNOUNCEMENT;
return (
<AnnouncementBar
type={AnnouncementBarTypes.ANNOUNCEMENT}
showCloseButton={true}
id='cloud-pro-monthly-deprecation-announcement-bar'
type={announcementType}
showCloseButton={daysToProMonthlyEnd > 10}
onButtonClick={() => openPurchaseModal({trackingLocation: 'to_yearly_nudge_annoucement_bar'})}
modalButtonText={t('cloud_billing.nudge_to_yearly.learn_more')}
modalButtonDefaultText='Learn more'
message={<FormattedMessage {...message}/>}
message={message}
showLinkAsButton={true}
handleClose={savedDismissedPref}
handleClose={showBanner}
/>
);
};
@@ -90,17 +182,22 @@ const ToYearlyNudgeBanner = () => {
return null;
}
const now = moment(Date.now());
const proMonthlyEndDate = moment(cloudProMonthlyCloseMoment, 'YYYYMMDD');
const daysToProMonthlyEnd = proMonthlyEndDate.diff(now, 'days');
const title = (
<FormattedMessage
id='cloud_billing.nudge_to_yearly.title'
defaultMessage='Switch to an annual plan today'
defaultMessage='Action required: Switch to annual billing to keep your workspace.'
/>
);
const description = (
<FormattedMessage
id='cloud_billing.nudge_to_yearly.description'
defaultMessage='Simplify your billing by switching to an annual subscription.'
defaultMessage='Monthly billing will be discontinued on {date}. To keep your workspace, switch to annual billing.'
values={{date: moment(cloudProMonthlyCloseMoment, 'YYYYMMDD').format('MMMM DD, YYYY')}}
/>
);
@@ -122,9 +219,12 @@ const ToYearlyNudgeBanner = () => {
</button>
);
const bannerMode = (daysToProMonthlyEnd <= 10) ? 'danger' : 'info';
return (
<AlertBanner
mode='info'
id='cloud-pro-monthly-deprecation-alert-banner'
mode={bannerMode}
title={title}
message={description}
className='ToYearlyNudgeBanner'

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

@@ -6,7 +6,7 @@ import React from 'react';
import {injectIntl, WrappedComponentProps} from 'react-intl';
import {Feedback} from '@mattermost/types/cloud';
import FeedbackModal from 'components/feedback_modal/feedback';
import FeedbackModal, {FeedbackOption} from 'components/feedback_modal/feedback';
type Props = {
onSubmit: (deleteFeedback: Feedback) => void;
@@ -28,23 +28,35 @@ const DeleteFeedbackModal = (props: Props) => {
defaultMessage: 'Delete Workspace',
});
const deleteFeedbackOptions = [
props.intl.formatMessage({
id: 'feedback.deleteWorkspace.feedbackNoValue',
defaultMessage: 'No longer found value',
}),
props.intl.formatMessage({
id: 'feedback.deleteWorkspace.feedbackMoving',
defaultMessage: 'Moving to a different solution',
}),
props.intl.formatMessage({
id: 'feedback.deleteWorkspace.feedbackMistake',
defaultMessage: 'Created a workspace by mistake',
}),
props.intl.formatMessage({
id: 'feedback.deleteWorkspace.feedbackHosting',
defaultMessage: 'Moving to hosting my own Mattermost instance (self-hosted)',
}),
const deleteFeedbackOptions: FeedbackOption[] = [
{
translatedMessage: props.intl.formatMessage({
id: 'feedback.deleteWorkspace.feedbackNoValue',
defaultMessage: 'No longer found value',
}),
submissionValue: 'No longer found value',
},
{
translatedMessage: props.intl.formatMessage({
id: 'feedback.deleteWorkspace.feedbackMoving',
defaultMessage: 'Moving to a different solution',
}),
submissionValue: 'Moving to a different solution',
},
{
translatedMessage: props.intl.formatMessage({
id: 'feedback.deleteWorkspace.feedbackMistake',
defaultMessage: 'Created a workspace by mistake',
}),
submissionValue: 'Created a workspace by mistake',
},
{
translatedMessage: props.intl.formatMessage({
id: 'feedback.deleteWorkspace.feedbackHosting',
defaultMessage: 'Moving to hosting my own Mattermost instance (self-hosted)',
}),
submissionValue: 'Moving to hosting my own Mattermost instance (self-hosted)',
},
];
return (

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

@@ -4,7 +4,7 @@
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {recycleDatabaseConnection} from 'actions/admin_actions.jsx';
import {recycleDatabaseConnection, ping} from 'actions/admin_actions';
import * as Utils from 'utils/utils';
import {t} from 'utils/i18n';
@@ -19,6 +19,15 @@ import TextSetting from './text_setting';
import MigrationsTable from './database';
export default class DatabaseSettings extends AdminSettings {
constructor(props) {
super(props);
this.state = {
...this.state,
searchBackend: '',
};
}
getConfigFromState = (config) => {
// driverName and dataSource are read-only from the UI
@@ -34,6 +43,17 @@ export default class DatabaseSettings extends AdminSettings {
return config;
};
componentDidMount() {
this.getSearchBackend().then((searchBackend) => {
this.setState({searchBackend});
});
}
async getSearchBackend() {
const res = await ping()();
return res.ActiveSearchBackend;
}
getStateFromConfig(config) {
return {
driverName: config.SqlSettings.DriverName,
@@ -368,6 +388,30 @@ export default class DatabaseSettings extends AdminSettings {
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
>
<FormattedMessage
id='admin.database.search_backend.title'
defaultMessage='Active Search Backend:'
/>
</label>
<div className='col-sm-8'>
<input
type='text'
className='form-control'
value={this.state.searchBackend}
disabled={true}
/>
<div className='help-text'>
<FormattedMessage
id='admin.database.search_backend.help_text'
defaultMessage='Shows the currently active backend used for search. Values can be none, database, elasticsearch, bleve etc.'
/>
</div>
</div>
</div>
</SettingsGroup>
);
};

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

@@ -7,8 +7,14 @@ import {shallow} from 'enzyme';
import DatabaseSettings from 'components/admin_console/database_settings.jsx';
jest.mock('actions/admin_actions.jsx', () => {
const pingFn = () => {
return jest.fn(() => {
return {ActiveSearchBackend: 'none'};
});
};
return {
recycleDatabaseConnection: jest.fn(),
ping: pingFn,
};
});

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

@@ -69,7 +69,7 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
admin: {
config: {
ServiceSettings: {
SelfHostedExpansion: true,
SelfHostedPurchase: true,
},
},
},

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

@@ -59,13 +59,13 @@ const EnterpriseEditionLeftPanel = ({
const canExpand = useCanSelfHostedExpand();
const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'license_settings_add_seats'});
const expandableLink = useSelector(getExpandSeatsLink);
const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedExpansion;
const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase;
const query = useQuery();
const actionQueryParam = query.get('action');
useEffect(() => {
if (actionQueryParam === 'show_expansion_modal') {
if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedPurchaseEnabled) {
selfHostedExpansionModal.open();
}
}, []);
@@ -99,7 +99,7 @@ const EnterpriseEditionLeftPanel = ({
);
const handleClickAddSeats = () => {
if (!isSelfHostedExpansionEnabled || !canExpand) {
if (!isSelfHostedPurchaseEnabled || !canExpand) {
window.open(expandableLink(unsanitizedLicense.Id), '_blank');
} else {
selfHostedExpansionModal.open();

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

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import React, {useEffect, useState, ReactNode} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
@@ -106,7 +106,7 @@ const TrialBanner = ({
const dispatch = useDispatch();
const btnText = (status: TrialLoadStatus): string => {
const btnText = (status: TrialLoadStatus) => {
switch (status) {
case TrialLoadStatus.Started:
return formatMessage({id: 'start_trial.modal.gettingTrial', defaultMessage: 'Getting Trial...'});
@@ -115,7 +115,22 @@ const TrialBanner = ({
case TrialLoadStatus.Failed:
return formatMessage({id: 'start_trial.modal.failed', defaultMessage: 'Failed'});
case TrialLoadStatus.Embargoed:
return formatMessage({id: 'admin.license.trial-request.embargoed'});
return formatMessage<ReactNode>(
{
id: 'admin.license.trial-request.embargoed',
defaultMessage: 'We were unable to process the request due to limitations for embargoed countries. <link>Learn more in our documentation</link>, or reach out to legal@mattermost.com for questions around export limitations.',
},
{
link: (text: string) => (
<ExternalLink
location='trial_banner'
href={LicenseLinks.EMBARGOED_COUNTRIES}
>
{text}
</ExternalLink>
),
},
);
default:
return formatMessage({id: 'admin.license.trial-request.startTrial', defaultMessage: 'Start trial'});
}

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

@@ -20,7 +20,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
>
<MemoizedFormattedMessage
defaultMessage="Button Text"
id="test"
id="test2"
/>
</LoadingWrapper>
</button>
@@ -30,7 +30,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
>
<MemoizedFormattedMessage
defaultMessage="Help Text"
id="test"
id="test1"
/>
</div>
</div>
@@ -42,7 +42,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
buttonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Button Text"
id="test"
id="test2"
/>
}
disabled={false}
@@ -55,7 +55,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Help Text"
id="test"
id="test1"
/>
}
includeDetailedError={true}
@@ -103,7 +103,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
>
<FormattedMessage
defaultMessage="Button Text"
id="test"
id="test2"
>
<span>
Button Text
@@ -154,7 +154,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
>
<FormattedMessage
defaultMessage="Help Text"
id="test"
id="test1"
>
<span>
Help Text
@@ -171,7 +171,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
buttonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Button Text"
id="test"
id="test2"
/>
}
disabled={false}
@@ -184,7 +184,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Help Text"
id="test"
id="test1"
/>
}
includeDetailedError={false}
@@ -240,7 +240,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
>
<FormattedMessage
defaultMessage="Button Text"
id="test"
id="test2"
>
<span>
Button Text
@@ -291,7 +291,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
>
<FormattedMessage
defaultMessage="Help Text"
id="test"
id="test1"
>
<span>
Help Text
@@ -308,7 +308,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
buttonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Button Text"
id="test"
id="test2"
/>
}
disabled={false}
@@ -321,7 +321,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Help Text"
id="test"
id="test1"
/>
}
includeDetailedError={false}
@@ -369,7 +369,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
>
<FormattedMessage
defaultMessage="Button Text"
id="test"
id="test2"
>
<span>
Button Text
@@ -413,7 +413,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
>
<FormattedMessage
defaultMessage="Help Text"
id="test"
id="test1"
>
<span>
Help Text
@@ -430,7 +430,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
buttonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Button Text"
id="test"
id="test2"
/>
}
disabled={false}
@@ -443,7 +443,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Help Text"
id="test"
id="test1"
/>
}
includeDetailedError={false}
@@ -499,7 +499,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
>
<FormattedMessage
defaultMessage="Button Text"
id="test"
id="test2"
>
<span>
Button Text
@@ -513,7 +513,7 @@ exports[`components/admin_console/request_button/request_button.jsx should match
>
<FormattedMessage
defaultMessage="Help Text"
id="test"
id="test1"
>
<span>
Help Text

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

@@ -18,13 +18,13 @@ describe('components/admin_console/request_button/request_button.jsx', () => {
requestAction={emptyFunction}
helpText={
<FormattedMessage
id='test'
id='test1'
defaultMessage='Help Text'
/>
}
buttonText={
<FormattedMessage
id='test'
id='test2'
defaultMessage='Button Text'
/>
}
@@ -42,13 +42,13 @@ describe('components/admin_console/request_button/request_button.jsx', () => {
requestAction={requestActionSuccess}
helpText={
<FormattedMessage
id='test'
id='test1'
defaultMessage='Help Text'
/>
}
buttonText={
<FormattedMessage
id='test'
id='test2'
defaultMessage='Button Text'
/>
}
@@ -72,13 +72,13 @@ describe('components/admin_console/request_button/request_button.jsx', () => {
requestAction={requestActionSuccess}
helpText={
<FormattedMessage
id='test'
id='test1'
defaultMessage='Help Text'
/>
}
buttonText={
<FormattedMessage
id='test'
id='test2'
defaultMessage='Button Text'
/>
}
@@ -102,13 +102,13 @@ describe('components/admin_console/request_button/request_button.jsx', () => {
requestAction={requestActionSuccess}
helpText={
<FormattedMessage
id='test'
id='test1'
defaultMessage='Help Text'
/>
}
buttonText={
<FormattedMessage
id='test'
id='test2'
defaultMessage='Button Text'
/>
}
@@ -129,13 +129,13 @@ describe('components/admin_console/request_button/request_button.jsx', () => {
requestAction={requestActionSuccess}
helpText={
<FormattedMessage
id='test'
id='test1'
defaultMessage='Help Text'
/>
}
buttonText={
<FormattedMessage
id='test'
id='test2'
defaultMessage='Button Text'
/>
}
@@ -164,13 +164,13 @@ describe('components/admin_console/request_button/request_button.jsx', () => {
requestAction={requestActionFailure}
helpText={
<FormattedMessage
id='test'
id='test1'
defaultMessage='Help Text'
/>
}
buttonText={
<FormattedMessage
id='test'
id='test2'
defaultMessage='Button Text'
/>
}
@@ -191,13 +191,13 @@ describe('components/admin_console/request_button/request_button.jsx', () => {
requestAction={requestActionFailure}
helpText={
<FormattedMessage
id='test'
id='test1'
defaultMessage='Help Text'
/>
}
buttonText={
<FormattedMessage
id='test'
id='test2'
defaultMessage='Button Text'
/>
}

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

@@ -671,7 +671,7 @@ export default class SchemaAdminSettings extends React.PureComponent {
};
handleGeneratedChange = (id, s) => {
this.handleChange(id, s.replace('+', '-').replace('/', '_'));
this.handleChange(id, s.replace(/\+/g, '-').replace(/\//g, '_'));
};
handleChange = (id, value, confirm = false, doSubmit = false, warning = false) => {

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

@@ -95,7 +95,7 @@ export default class LogList extends React.PureComponent<Props, State> {
);
const level: JSX.Element = (
<FormattedMessage
id='admin.log.logLevel'
id='admin.log.Level'
defaultMessage='Level'
/>
);

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

@@ -213,9 +213,11 @@ exports[`admin_console/team_channel_settings/team/TeamProfile__Cloud restore sho
content="name"
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
@@ -242,6 +244,7 @@ exports[`admin_console/team_channel_settings/team/TeamProfile__Cloud restore sho
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,
@@ -457,9 +460,11 @@ exports[`admin_console/team_channel_settings/team/TeamProfile__Cloud should matc
content="name"
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
@@ -486,6 +491,7 @@ exports[`admin_console/team_channel_settings/team/TeamProfile__Cloud should matc
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,
@@ -603,9 +609,11 @@ exports[`admin_console/team_channel_settings/team/TeamProfile__Cloud should matc
id="sharedTooltip"
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
@@ -632,6 +640,7 @@ exports[`admin_console/team_channel_settings/team/TeamProfile__Cloud should matc
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,
@@ -834,9 +843,11 @@ exports[`admin_console/team_channel_settings/team/TeamProfile__Cloud should matc
content="name"
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
@@ -863,6 +874,7 @@ exports[`admin_console/team_channel_settings/team/TeamProfile__Cloud should matc
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,

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

@@ -6,7 +6,7 @@ import {shallow} from 'enzyme';
import ChipsList, {ChipsInfoType} from 'components/admin_console/workspace-optimization/chips_list';
import {ItemStatus} from './dashboard.data';
import {ItemStatus} from './dashboard.type';
describe('components/admin_console/workspace-optimization/chips_list', () => {
const overallScoreChips: ChipsInfoType = {

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

@@ -6,7 +6,7 @@ import {FormattedMessage} from 'react-intl';
import Chip from 'components/common/chip/chip';
import {ItemStatus} from './dashboard.data';
import {ItemStatus} from './dashboard.type';
import './dashboard.scss';

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

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
@@ -14,56 +14,23 @@ import {
AccountMultipleOutlineIcon,
} from '@mattermost/compass-icons/components';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getLicense, getServerVersion} from 'mattermost-redux/selectors/entities/general';
import {GlobalState} from '@mattermost/types/store';
import {CloudLinks, ConsolePages, DocLinks} from 'utils/constants';
import {ConsolePages} from 'utils/constants';
import {daysToLicenseExpire, isEnterpriseOrE20License, getIsStarterLicense} from '../../../utils/license_utils';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {AdminConfig} from '@mattermost/types/config';
import {runConfigChecks} from './dashboard_checks/config';
import {DataModel, ItemStatus, Options} from './dashboard.type';
import {runAccessChecks} from './dashboard_checks/access';
import {runDataPrivacyChecks} from './dashboard_checks/data_privacy';
import {runPerformanceChecks} from './dashboard_checks/performance';
import {runEaseOfUseChecks} from './dashboard_checks/easy_management';
import {runUpdateChecks} from './dashboard_checks/updates';
export type DataModel = {
[key: string]: {
title: string;
description: string;
descriptionOk: string;
items: ItemModel[];
icon: React.ReactNode;
hide?: boolean;
};
}
export enum ItemStatus {
NONE = 'none',
OK = 'ok',
INFO = 'info',
WARNING = 'warning',
ERROR = 'error',
}
export type ItemModel = {
id: string;
title: string;
description: string;
status: ItemStatus;
scoreImpact: number;
impactModifier: number;
configUrl?: string;
configText?: string;
telemetryAction?: string;
infoUrl?: string;
infoText?: string;
}
export type UpdatesParam = {
serverVersion: {
type: string;
status: ItemStatus;
description: string;
};
}
const impactModifiers: Record<ItemStatus, number> = {
export const impactModifiers: Record<ItemStatus, number> = {
[ItemStatus.NONE]: 1,
[ItemStatus.OK]: 1,
[ItemStatus.INFO]: 0.5,
@@ -71,11 +38,191 @@ const impactModifiers: Record<ItemStatus, number> = {
[ItemStatus.ERROR]: 0,
};
const useMetricsData = () => {
const getUpdatesData = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
) => ({
title: formatMessage({
id: 'admin.reporting.workspace_optimization.updates.title',
defaultMessage: 'Server updates',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.updates.description',
defaultMessage: 'An update is available.',
}),
descriptionOk: formatMessage({
id: 'admin.reporting.workspace_optimization.updates.descriptionOk',
defaultMessage: 'Your workspace is completely up to date!',
}),
icon: (
<div className='icon'>
<ArrowUpBoldCircleOutlineIcon
size={20}
color={'var(--sys-center-channel-color)'}
/>
</div>
),
items: await runUpdateChecks(config, formatMessage, options),
});
const getConfigurationData = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
) => ({
title: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.title',
defaultMessage: 'Configuration',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.description',
defaultMessage: 'You have configuration issues to resolve',
}),
hide: options.isCloud,
descriptionOk: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.descriptionOk',
defaultMessage: 'You\'ve successfully configured SSL and Session Lengths!',
}),
icon: (
<div className='icon'>
<TuneIcon
size={20}
color={'var(--sys-center-channel-color)'}
/>
</div>
),
items: await runConfigChecks(config, formatMessage, options),
});
const getAccessData = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
) => ({
title: formatMessage({
id: 'admin.reporting.workspace_optimization.access.title',
defaultMessage: 'Workspace access',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.access.description',
defaultMessage: 'Web server configuration may be affecting access to your Mattermost workspace.',
}),
hide: options.isCloud,
descriptionOk: formatMessage({
id: 'admin.reporting.workspace_optimization.access.descriptionOk',
defaultMessage: 'Your web server configuration is passing a live URL test!',
}),
icon: (
<div className='icon'>
<ServerVariantIcon
size={20}
color={'var(--sys-center-channel-color)'}
/>
</div>
),
items: await runAccessChecks(config, formatMessage),
});
const getPerformanceData = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
) => ({
title: formatMessage({
id: 'admin.reporting.workspace_optimization.performance.title',
defaultMessage: 'Performance',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.performance.description',
defaultMessage: 'Your server would benefit from some performance tweaks.',
}),
hide: options.isCloud,
descriptionOk: formatMessage({
id: 'admin.reporting.workspace_optimization.performance.descriptionOk',
defaultMessage: 'Your search performance suits your workspace usage!',
}),
icon: (
<div className='icon'>
<ChartLineIcon
size={20}
color={'var(--sys-center-channel-color)'}
/>
</div>
),
items: await runPerformanceChecks(config, formatMessage, options),
});
const getDataPrivacyData = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
) => ({
title: formatMessage({
id: 'admin.reporting.workspace_optimization.data_privacy.title',
defaultMessage: 'Data privacy',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.data_privacy.description',
defaultMessage: 'Get better insight and control over your data.',
}),
descriptionOk: formatMessage({
id: 'admin.reporting.workspace_optimization.data_privacy.descriptionOk',
defaultMessage: 'You\'ve enabled data retention and compliance features!',
}),
icon: (
<div className='icon'>
<LockOutlineIcon
size={20}
color={'var(--sys-center-channel-color)'}
/>
</div>
),
items: await runDataPrivacyChecks(config, formatMessage, options),
});
const getEaseOfManagementData = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
) => ({
title: formatMessage({
id: 'admin.reporting.workspace_optimization.ease_of_management.title',
defaultMessage: 'Ease of management',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.ease_of_management.description',
defaultMessage: 'Make it easier to manage your Mattermost workspace.',
}),
descriptionOk: formatMessage({
id: 'admin.reporting.workspace_optimization.ease_of_management.descriptionOk',
defaultMessage: 'Your user authentication setup is appropriate based on your current usage!',
}),
icon: (
<div className='icon'>
<AccountMultipleOutlineIcon
size={20}
color={'var(--sys-center-channel-color)'}
/>
</div>
),
items: await runEaseOfUseChecks(config, formatMessage, options),
});
const useMetricsData = (
config: Partial<AdminConfig>,
) => {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<DataModel | undefined>(undefined);
const {formatMessage} = useIntl();
const prevTrialLicense = useSelector((state: GlobalState) => state.entities.admin.prevTrialLicense);
const license = useSelector(getLicense);
// get the currently installed server version
const installedVersion = useSelector((state: GlobalState) => getServerVersion(state));
const analytics = useSelector((state: GlobalState) => state.entities.admin.analytics) as unknown as Options['analytics'];
const canStartTrial = license?.IsLicensed !== 'true' && prevTrialLicense?.IsLicensed !== 'true';
const daysUntilExpiration = daysToLicenseExpire(license) || -1;
@@ -87,358 +234,46 @@ const useMetricsData = () => {
const [, contactSalesLink] = useOpenSalesLink();
const trialOrEnterpriseCtaConfig = {
const trialOrEnterpriseCtaConfig = useMemo(() => ({
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'}),
}), [canStartTrial, contactSalesLink, formatMessage]);
const options: Options = useMemo(() => ({
isLicensed,
isEnterpriseLicense,
trialOrEnterpriseCtaConfig,
isStarterLicense,
isCloud,
analytics,
installedVersion,
}), [isLicensed, isEnterpriseLicense, trialOrEnterpriseCtaConfig, isStarterLicense, isCloud, analytics, installedVersion]);
useEffect(() => {
setLoading(true);
const refreshData = async () => {
const data = {
updates: await getUpdatesData(config, formatMessage, options),
configuration: await getConfigurationData(config, formatMessage, options),
access: await getAccessData(config, formatMessage, options),
performance: await getPerformanceData(config, formatMessage, options),
dataPrivacy: await getDataPrivacyData(config, formatMessage, options),
easyManagement: await getEaseOfManagementData(config, formatMessage, options),
};
return data;
};
refreshData().then((data) => {
setData(data);
setLoading(false);
});
}, [config, formatMessage, options]);
return {
data,
loading,
};
const getUpdatesData = (data: UpdatesParam) => ({
title: formatMessage({
id: 'admin.reporting.workspace_optimization.updates.title',
defaultMessage: 'Server updates',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.updates.description',
defaultMessage: 'An update is available.',
}),
descriptionOk: formatMessage({
id: 'admin.reporting.workspace_optimization.updates.descriptionOk',
defaultMessage: 'Your workspace is completely up to date!',
}),
icon: (
<div className='icon'>
<ArrowUpBoldCircleOutlineIcon
size={20}
color={'var(--sys-center-channel-color)'}
/>
</div>
),
items: [
{
id: 'server_version',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.updates.server_version.status.title',
defaultMessage: '{type} version update available.',
}, {type: data.serverVersion.type}),
description: data.serverVersion.description,
configUrl: CloudLinks.DOWNLOAD_UPDATE,
configText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.downloadUpdate', defaultMessage: 'Download update'}),
infoUrl: DocLinks.UPGRADE_SERVER,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'server-version',
status: data.serverVersion.status,
scoreImpact: 15,
impactModifier: impactModifiers[data.serverVersion.status],
},
],
});
type ConfigurationParam = {
ssl: {
status: ItemStatus;
};
sessionLength: {
status: ItemStatus;
};
}
const getConfigurationData = (data: ConfigurationParam) => ({
title: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.title',
defaultMessage: 'Configuration',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.description',
defaultMessage: 'You have configuration issues to resolve',
}),
hide: isCloud,
descriptionOk: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.descriptionOk',
defaultMessage: 'You\'ve successfully configured SSL and Session Lengths!',
}),
icon: (
<div className='icon'>
<TuneIcon
size={20}
color={'var(--sys-center-channel-color)'}
/>
</div>
),
items: [
{
id: 'ssl',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.ssl.title',
defaultMessage: 'Configure SSL to make your server more secure',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.ssl.description',
defaultMessage: 'We strongly recommend securing your Mattermost workspace by configuring SSL in production environments.',
}),
infoUrl: DocLinks.SSL_CERTIFICATE,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'ssl',
status: data.ssl.status,
scoreImpact: 25,
impactModifier: impactModifiers[data.ssl.status],
},
{
id: 'session-length',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.session_length.title',
defaultMessage: 'Session lengths is set to default',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.session_length.description',
defaultMessage: 'Your session length is set to the default of 30 days. A longer session length provides convenience, and a shorter session provides tighter security. We recommend adjusting this based on your organization\'s security policies.',
}),
configUrl: ConsolePages.SESSION_LENGTHS,
configText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.configureSessionLength', defaultMessage: 'Configure session length'}),
infoUrl: DocLinks.SESSION_LENGTHS,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'session-length',
status: data.sessionLength.status,
scoreImpact: 8,
impactModifier: impactModifiers[data.sessionLength.status],
},
],
});
type AccessParam = {
siteUrl: {
status: ItemStatus;
};
}
const getAccessData = (data: AccessParam) => ({
title: formatMessage({
id: 'admin.reporting.workspace_optimization.access.title',
defaultMessage: 'Workspace access',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.access.description',
defaultMessage: 'Web server configuration may be affecting access to your Mattermost workspace.',
}),
hide: isCloud,
descriptionOk: formatMessage({
id: 'admin.reporting.workspace_optimization.access.descriptionOk',
defaultMessage: 'Your web server configuration is passing a live URL test!',
}),
icon: (
<div className='icon'>
<ServerVariantIcon
size={20}
color={'var(--sys-center-channel-color)'}
/>
</div>
),
items: [
{
id: 'site-url',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.access.site_url.title',
defaultMessage: 'Misconfigured web server',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.access.site_url.description',
defaultMessage: 'Your web server settings aren\'t passing a live URL test which means your workspace may not be accessible to users. We recommend updating your web server settings.',
}),
configUrl: ConsolePages.WEB_SERVER,
configText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.configureWebServer', defaultMessage: 'Configure web server'}),
infoUrl: DocLinks.SITE_URL,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'site-url',
status: data.siteUrl.status,
scoreImpact: 12,
impactModifier: impactModifiers[data.siteUrl.status],
},
],
});
type PerformanceParam = {
search: {
status: ItemStatus;
};
}
const getPerformanceData = (data: PerformanceParam) => ({
title: formatMessage({
id: 'admin.reporting.workspace_optimization.performance.title',
defaultMessage: 'Performance',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.performance.description',
defaultMessage: 'Your server would benefit from some performance tweaks.',
}),
hide: isCloud,
descriptionOk: formatMessage({
id: 'admin.reporting.workspace_optimization.performance.descriptionOk',
defaultMessage: 'Your search performance suits your workspace usage!',
}),
icon: (
<div className='icon'>
<ChartLineIcon
size={20}
color={'var(--sys-center-channel-color)'}
/>
</div>
),
items: [
{
id: 'search',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.performance.search.title',
defaultMessage: 'Search performance',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.performance.search.description',
defaultMessage: 'Your server has reached over 500 users and 2 million posts which can result in slow search performance. We recommend enabling Elasticsearch for better performance.',
}),
...(isLicensed && isEnterpriseLicense ? {
configUrl: ConsolePages.ELASTICSEARCH,
configText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.configureElasticsearch', defaultMessage: 'Try Elasticsearch'}),
} : trialOrEnterpriseCtaConfig),
infoUrl: DocLinks.ELASTICSEARCH,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'search-optimization',
status: data.search.status,
scoreImpact: 20,
impactModifier: impactModifiers[data.search.status],
},
],
});
type DataPrivacyParam = {
retention: {
status: ItemStatus;
};
}
// TBD
const getDataPrivacyData = (data: DataPrivacyParam) => ({
title: formatMessage({
id: 'admin.reporting.workspace_optimization.data_privacy.title',
defaultMessage: 'Data privacy',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.data_privacy.description',
defaultMessage: 'Get better insight and control over your data.',
}),
descriptionOk: formatMessage({
id: 'admin.reporting.workspace_optimization.data_privacy.descriptionOk',
defaultMessage: 'You\'ve enabled data retention and compliance features!',
}),
icon: (
<div className='icon'>
<LockOutlineIcon
size={20}
color={'var(--sys-center-channel-color)'}
/>
</div>
),
items: [
{
id: 'data-retention',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.data_privacy.retention.title',
defaultMessage: 'Become more data aware',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.data_privacy.retention.description',
defaultMessage: 'Organizations in highly regulated industries require more control and insight with their data. We recommend enabling Data Retention and Compliance features.',
}),
...(isLicensed && isEnterpriseLicense ? {
configUrl: ConsolePages.DATA_RETENTION,
configText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.configureDataRetention', defaultMessage: 'Try data retention'}),
} : trialOrEnterpriseCtaConfig),
infoUrl: DocLinks.DATA_RETENTION_POLICY,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'data-retention',
status: data.retention.status,
scoreImpact: 16,
impactModifier: impactModifiers[data.retention.status],
},
],
});
type EaseOfManagementParam = {
ldap: {
status: ItemStatus;
};
guestAccounts?: {
status: ItemStatus;
};
}
// TBD
const getEaseOfManagementData = (data: EaseOfManagementParam) => ({
title: formatMessage({
id: 'admin.reporting.workspace_optimization.ease_of_management.title',
defaultMessage: 'Ease of management',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.ease_of_management.description',
defaultMessage: 'Make it easier to manage your Mattermost workspace.',
}),
descriptionOk: formatMessage({
id: 'admin.reporting.workspace_optimization.ease_of_management.descriptionOk',
defaultMessage: 'Your user authentication setup is appropriate based on your current usage!',
}),
icon: (
<div className='icon'>
<AccountMultipleOutlineIcon
size={20}
color={'var(--sys-center-channel-color)'}
/>
</div>
),
items: [
{
id: 'ad-ldap',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.ease_of_management.ldap.title',
defaultMessage: 'AD/LDAP integration recommended',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.ease_of_management.ldap.description',
defaultMessage: 'You\'ve reached over 100 users! We recommend setting up AD/LDAP user authentication for easier onboarding as well as automated deactivations and role assignments.',
}),
...(isLicensed && !isStarterLicense ? {
configUrl: ConsolePages.AD_LDAP,
configText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.configureLDAP', defaultMessage: 'Try AD/LDAP'}),
} : trialOrEnterpriseCtaConfig),
infoUrl: DocLinks.AD_LDAP,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'ad-ldap',
status: data.ldap.status,
scoreImpact: 22,
impactModifier: impactModifiers[data.ldap.status],
},
// commented out for now.
// @see discussion here: https://github.com/mattermost/mattermost-webapp/pull/9822#discussion_r806879385
// {
// id: 'guest-accounts',
// title: formatMessage({
// id: 'admin.reporting.workspace_optimization.ease_of_management.guests_accounts.title',
// defaultMessage: 'Guest Accounts recommended',
// }),
// description: formatMessage({
// id: 'admin.reporting.workspace_optimization.ease_of_management.guests_accounts.description',
// defaultMessage: 'Several user accounts are using different domains than your Site URL. You can control user access to channels and teams with guest accounts. We recommend starting an Enterprise trial and enabling Guest Access.',
// }),
// ...trialOrEnterpriseCtaConfig,
// infoUrl: 'https://docs.mattermost.com/onboard/guest-accounts.html',
// infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
// telemetryAction: 'guest-accounts',
// status: data.guestAccounts.status,
// scoreImpact: 6,
// impactModifier: impactModifiers[data.guestAccounts.status],
// },
],
});
return {getAccessData, getConfigurationData, getUpdatesData, getPerformanceData, getDataPrivacyData, getEaseOfManagementData, isLicensed, isEnterpriseLicense};
};
export default useMetricsData;

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

@@ -4,17 +4,11 @@
import {CheckIcon} from '@mattermost/compass-icons/components';
import classNames from 'classnames';
import React, {useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import React from 'react';
import styled from 'styled-components';
import {GlobalState} from '@mattermost/types/store';
import {getServerVersion} from 'mattermost-redux/selectors/entities/general';
import {Client4} from 'mattermost-redux/client';
import Accordion, {AccordionItemType} from 'components/common/accordion/accordion';
import {elasticsearchTest, ldapTest, testSiteURL} from '../../../actions/admin_actions';
import LoadingScreen from '../../loading_screen';
import FormattedAdminHeader from '../../widgets/admin_console/formatted_admin_header';
import {Props} from '../admin_console';
@@ -22,10 +16,11 @@ import {Props} from '../admin_console';
import ChipsList, {ChipsInfoType} from './chips_list';
import CtaButtons from './cta_buttons';
import useMetricsData, {DataModel, ItemStatus, UpdatesParam} from './dashboard.data';
import useMetricsData from './dashboard.data';
import './dashboard.scss';
import OverallScore from './overall-score';
import {ItemStatus} from './dashboard.type';
const AccordionItem = styled.div`
padding: 12px;
@@ -49,180 +44,7 @@ const successIcon = (
);
const WorkspaceOptimizationDashboard = (props: Props) => {
const [loading, setLoading] = useState(true);
const [versionData, setVersionData] = useState<UpdatesParam['serverVersion']>({type: '', description: '', status: ItemStatus.NONE});
// const [guestAccountStatus, setGuestAccountStatus] = useState<ItemStatus>('none');
const [liveUrlStatus, setLiveUrlStatus] = useState<ItemStatus>(ItemStatus.ERROR);
const [elastisearchStatus, setElasticsearchStatus] = useState<ItemStatus>(ItemStatus.INFO);
const [ldapStatus, setLdapStatus] = useState<ItemStatus>(ItemStatus.INFO);
const [dataRetentionStatus, setDataRetentionStatus] = useState<ItemStatus>(ItemStatus.INFO);
const {formatMessage} = useIntl();
const {getAccessData, getConfigurationData, getUpdatesData, getPerformanceData, getDataPrivacyData, getEaseOfManagementData, isLicensed, isEnterpriseLicense} = useMetricsData();
// get the currently installed server version
const installedVersion = useSelector((state: GlobalState) => getServerVersion(state));
const analytics = useSelector((state: GlobalState) => state.entities.admin.analytics);
const {TOTAL_USERS: totalUsers, TOTAL_POSTS: totalPosts} = analytics!;
// gather locally available data
const {
ServiceSettings,
DataRetentionSettings,
ElasticsearchSettings,
LdapSettings,
// TeamSettings,
// GuestAccountsSettings,
} = props.config;
const {location} = document;
const sessionLengthWebInHours = ServiceSettings?.SessionLengthWebInHours || -1;
const testURL = () => {
if (!ServiceSettings?.SiteURL) {
return Promise.resolve();
}
const onSuccess = ({status}: any) => setLiveUrlStatus(status === 'OK' ? ItemStatus.OK : ItemStatus.ERROR);
const onError = () => setLiveUrlStatus(ItemStatus.ERROR);
return testSiteURL(onSuccess, onError, ServiceSettings?.SiteURL);
};
const testDataRetention = async () => {
if (!isLicensed || !isEnterpriseLicense) {
return Promise.resolve();
}
if (DataRetentionSettings?.EnableMessageDeletion || DataRetentionSettings?.EnableFileDeletion) {
setDataRetentionStatus(ItemStatus.OK);
return Promise.resolve();
}
const result = await fetch(`${Client4.getBaseRoute()}/data_retention/policies?page=0&per_page=0`).then((result) => result.json());
setDataRetentionStatus(result.total_count > 0 ? ItemStatus.OK : ItemStatus.INFO);
return Promise.resolve();
};
const fetchVersion = async () => {
const result = await fetch(`${Client4.getBaseRoute()}/latest_version`).then((result) => result.json());
if (result.tag_name) {
const sanitizedVersion = result.tag_name.startsWith('v') ? result.tag_name.slice(1) : result.tag_name;
const newVersionParts = sanitizedVersion.split('.');
const installedVersionParts = installedVersion.split('.').slice(0, 3);
// quick general check if a newer version is available
let type = '';
let status: ItemStatus = ItemStatus.OK;
if (newVersionParts.join('') > installedVersionParts.join('')) {
// get correct values to be inserted into the accordion item
switch (true) {
case newVersionParts[0] > installedVersionParts[0]:
type = formatMessage({
id: 'admin.reporting.workspace_optimization.updates.server_version.update_type.major',
defaultMessage: 'Major',
});
status = ItemStatus.ERROR;
break;
case newVersionParts[1] > installedVersionParts[1]:
type = formatMessage({
id: 'admin.reporting.workspace_optimization.updates.server_version.update_type.minor',
defaultMessage: 'Minor',
});
status = ItemStatus.WARNING;
break;
case newVersionParts[2] > installedVersionParts[2]:
type = formatMessage({
id: 'admin.reporting.workspace_optimization.updates.server_version.update_type.patch',
defaultMessage: 'Patch',
});
status = ItemStatus.INFO;
break;
}
}
setVersionData({type, description: result.body, status});
}
};
const testElasticsearch = () => {
if (!isLicensed || !isEnterpriseLicense || !(ElasticsearchSettings?.EnableIndexing && ElasticsearchSettings?.EnableSearching)) {
return Promise.resolve();
}
const onSuccess = ({status}: any) => setElasticsearchStatus(status === 'OK' ? ItemStatus.OK : ItemStatus.INFO);
const onError = () => setElasticsearchStatus(ItemStatus.INFO);
return elasticsearchTest(props.config, onSuccess, onError);
};
const testLdap = () => {
if (!isLicensed || !LdapSettings?.Enable) {
return Promise.resolve();
}
const onSuccess = ({status}: any) => setLdapStatus(status === 'OK' ? ItemStatus.OK : ItemStatus.INFO);
const onError = () => setLdapStatus(ItemStatus.INFO);
return ldapTest(onSuccess, onError);
};
// commented out for now.
// @see discussion here: https://github.com/mattermost/mattermost-webapp/pull/9822#discussion_r806879385
// const fetchGuestAccounts = async () => {
// if (TeamSettings?.EnableOpenServer && GuestAccountsSettings?.Enable) {
// let usersArray = await fetch(`${Client4.getBaseRoute()}/users/invalid_emails`).then((result) => result.json());
//
// // this setting is just a string with a list of domains, or an empty string
// if (GuestAccountsSettings?.RestrictCreationToDomains) {
// const domainList = GuestAccountsSettings?.RestrictCreationToDomains;
// usersArray = usersArray.filter(({email}: Record<string, unknown>) => domainList.includes((email as string).split('@')[1]));
// }
//
// // if guest accounts make up more than 5% of the user base show the info accordion
// if (usersArray.length > (totalUsers as number * 0.05)) {
// setGuestAccountStatus(ItemStatus.INFO);
// return;
// }
// }
//
// setGuestAccountStatus(ItemStatus.OK);
// };
useEffect(() => {
const promises = [];
promises.push(testURL());
promises.push(testLdap());
promises.push(fetchVersion());
promises.push(testElasticsearch());
promises.push(testDataRetention());
// promises.push(fetchGuestAccounts());
Promise.all(promises).then(() => setLoading(false));
}, [props.config, isLicensed, isEnterpriseLicense]);
const data: DataModel = {
updates: getUpdatesData({serverVersion: versionData}),
configuration: getConfigurationData({
ssl: {status: location.protocol === 'https:' ? ItemStatus.OK : ItemStatus.ERROR},
sessionLength: {status: sessionLengthWebInHours === 720 ? ItemStatus.INFO : ItemStatus.OK},
}),
access: getAccessData({siteUrl: {status: liveUrlStatus}}),
performance: getPerformanceData({
search: {
status: totalPosts < 2_000_000 && totalUsers < 500 ? ItemStatus.OK : elastisearchStatus,
},
}),
dataPrivacy: getDataPrivacyData({retention: {status: dataRetentionStatus}}),
easyManagement: getEaseOfManagementData({
ldap: {status: totalUsers < 100 ? ItemStatus.OK : ldapStatus},
// guestAccounts: {status: guestAccountStatus},
}),
};
const {data, loading} = useMetricsData(props.config);
const overallScoreChips: ChipsInfoType = {
[ItemStatus.INFO]: 0,
@@ -236,7 +58,7 @@ const WorkspaceOptimizationDashboard = (props: Props) => {
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const accData: AccordionItemType[] = Object.entries(data).filter(([_, y]) => !y.hide).map(([accordionKey, accordionData]) => {
const accordionItemsData: AccordionItemType[] | undefined = data && Object.entries(data).filter(([_, y]) => !y.hide).map(([accordionKey, accordionData]) => {
const accordionDataChips: ChipsInfoType = {
[ItemStatus.INFO]: 0,
[ItemStatus.WARNING]: 0,
@@ -297,7 +119,7 @@ const WorkspaceOptimizationDashboard = (props: Props) => {
};
});
return loading ? <LoadingScreen/> : (
return loading || !accordionItemsData ? <LoadingScreen/> : (
<div className='WorkspaceOptimizationDashboard wrapper--fixed'>
<FormattedAdminHeader
id={'admin.reporting.workspace_optimization.title'}
@@ -314,7 +136,7 @@ const WorkspaceOptimizationDashboard = (props: Props) => {
chartValue={Math.floor((overallScore.current / overallScore.max) * 100)}
/>
<Accordion
accordionItemsData={accData}
accordionItemsData={accordionItemsData}
expandMultiple={true}
/>
</div>

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

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type DataModel = {
[key: string]: {
title: string;
description: string;
descriptionOk: string;
items: ItemModel[];
icon: React.ReactNode;
hide?: boolean;
};
}
export enum ItemStatus {
/** Return NONE if it's not relevant, not configured, or not an option */
NONE = 'none',
/** Return OK if all checks for this have passed */
OK = 'ok',
/** Return info if it might not be relevant to them, but they could utilize it */
INFO = 'info',
WARNING = 'warning',
ERROR = 'error',
}
export type ItemModel = {
id: string;
title: string;
description: string;
status: ItemStatus;
scoreImpact: number;
impactModifier: number;
configUrl?: string;
configText?: string;
telemetryAction?: string;
infoUrl?: string;
infoText?: string;
}
export type UpdatesParam = {
serverVersion: {
type: string;
status: ItemStatus;
description: string;
};
}
type Analytics = {
DAILY_ACTIVE_USERS: number;
MONTHLY_ACTIVE_USERS: number;
TOTAL_INACTIVE_USERS: number;
TOTAL_MASTER_DB_CONNECTIONS: number;
TOTAL_POSTS: number;
TOTAL_PRIVATE_GROUPS: number;
TOTAL_PUBLIC_CHANNELS: number;
TOTAL_READ_DB_CONNECTIONS: number;
TOTAL_TEAMS: number;
TOTAL_USERS: number;
TOTAL_WEBSOCKET_CONNECTIONS: number;
}
export type Options = {
isLicensed: boolean;
isEnterpriseLicense: boolean;
trialOrEnterpriseCtaConfig: {
configUrl: string;
configText: string;
};
isCloud: boolean;
isStarterLicense: boolean;
analytics: Analytics | undefined;
installedVersion: string;
};

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

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useIntl} from 'react-intl';
import {ItemModel, ItemStatus} from '../dashboard.type';
import {ConsolePages, DocLinks} from 'utils/constants';
import {impactModifiers} from '../dashboard.data';
import {AdminConfig} from '@mattermost/types/config';
import {testSiteURL} from 'actions/admin_actions';
/**
*
* @description Checking to see if the siteURL is configured correctly by running it through the same "check siteURL" button that exists on the webserver page.
*/
const siteURLCheck = async (config: Partial<AdminConfig>, formatMessage: ReturnType<typeof useIntl>['formatMessage']): Promise<ItemModel> => {
let status = ItemStatus.OK;
const testURL = async () => {
if (!config.ServiceSettings?.SiteURL) {
status = ItemStatus.ERROR;
}
const onSuccess = ({status: s}: any) => {
if (s === 'OK') {
status = ItemStatus.OK;
}
};
const onError = () => {
status = ItemStatus.ERROR;
};
await testSiteURL(onSuccess, onError, config.ServiceSettings?.SiteURL);
};
await testURL();
return {
id: 'site-url',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.access.site_url.title',
defaultMessage: 'Misconfigured web server',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.access.site_url.description',
defaultMessage: 'Your web server settings aren\'t passing a live URL test which means your workspace may not be accessible to users. We recommend updating your web server settings.',
}),
configUrl: ConsolePages.WEB_SERVER,
configText: formatMessage({id: 'admin.reporting.workspace_optimization.access.site_url.cta', defaultMessage: 'Configure web server'}),
infoUrl: DocLinks.SITE_URL,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'site-url',
status,
scoreImpact: 12,
impactModifier: impactModifiers[status],
};
};
const checks = [
siteURLCheck,
];
export const runAccessChecks = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
) => {
const results = await Promise.all(checks.map((check) => check(config, formatMessage)));
return results;
};

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

@@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable @typescript-eslint/no-unused-vars */
import {AdminConfig} from '@mattermost/types/config';
import {useIntl} from 'react-intl';
import {ConsolePages, DocLinks} from 'utils/constants';
import {ItemModel, ItemStatus, Options} from '../dashboard.type';
import {impactModifiers} from '../dashboard.data';
/**
*
* @description This checks to see if the user's active session is done over https. This does not check if the server is configured to use https.
*/
const ssl = (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
): ItemModel => {
const status = document.location.protocol === 'https:' ? ItemStatus.OK : ItemStatus.ERROR;
return {
id: 'ssl',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.ssl.title',
defaultMessage: 'Configure SSL to make your server more secure',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.ssl.description',
defaultMessage: 'We strongly recommend securing your Mattermost workspace by configuring SSL in production environments.',
}),
infoUrl: DocLinks.SSL_CERTIFICATE,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'ssl',
status,
scoreImpact: 25,
impactModifier: impactModifiers[status],
};
};
/**
*
* @description This checks to see if the user has adjusted the default session lengths to something other than 720 hours.
*/
const sessionLength = (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
): ItemModel => {
const status = config.ServiceSettings?.SessionLengthMobileInHours === 720 ? ItemStatus.INFO : ItemStatus.OK;
return {
id: 'session-length',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.session_length.title',
defaultMessage: 'Session lengths is set to default',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.configuration.session_length.description',
defaultMessage: 'Your session length is set to the default of 30 days. A longer session length provides convenience, and a shorter session provides tighter security. We recommend adjusting this based on your organization\'s security policies.',
}),
configUrl: ConsolePages.SESSION_LENGTHS,
configText: formatMessage({id: 'admin.reporting.workspace_optimization.configuration.session_length.cta', defaultMessage: 'Configure session length'}),
infoUrl: DocLinks.SESSION_LENGTHS,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'session-length',
status,
scoreImpact: 8,
impactModifier: impactModifiers[status],
};
};
export const runConfigChecks = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
) => {
const checks = [
ssl,
sessionLength,
];
const results = await Promise.all(checks.map((check) => check(config, formatMessage, options)));
return results;
};

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

@@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useIntl} from 'react-intl';
import {impactModifiers} from '../dashboard.data';
import {ConsolePages, DocLinks} from 'utils/constants';
import {Client4} from 'mattermost-redux/client';
import {ItemStatus, Options} from '../dashboard.type';
import {AdminConfig} from '@mattermost/types/config';
/**
*
* @description Checks if they they have a global policy deletion enabled, or if a custom policy has been created.
*/
const dataRetentionCheck = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
) => {
const testDataRetention = async (
config: Partial<AdminConfig>,
options: Options,
) => {
if (!options.isLicensed || !options.isEnterpriseLicense) {
return ItemStatus.INFO;
}
if (config.DataRetentionSettings?.EnableMessageDeletion || config.DataRetentionSettings?.EnableFileDeletion) {
return ItemStatus.OK;
}
const policyCount: {total_count: number} = await fetch(`${Client4.getBaseRoute()}/data_retention/policies_count`).then((result) => result.json());
return policyCount.total_count > 0 ? ItemStatus.OK : ItemStatus.INFO;
};
const status = await testDataRetention(config, options);
return {
id: 'data-retention',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.data_privacy.retention.title',
defaultMessage: 'Become more data aware',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.data_privacy.retention.description',
defaultMessage: 'Organizations in highly regulated industries require more control and insight with their data. We recommend enabling Data Retention and Compliance features.',
}),
...(options.isLicensed && options.isEnterpriseLicense ? {
configUrl: ConsolePages.DATA_RETENTION,
configText: formatMessage({id: 'admin.reporting.workspace_optimization.data_privacy.retention.cta', defaultMessage: 'Try data retention'}),
} : options.trialOrEnterpriseCtaConfig),
infoUrl: DocLinks.DATA_RETENTION_POLICY,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'data-retention',
status,
scoreImpact: 16,
impactModifier: impactModifiers[status],
};
};
export const runDataPrivacyChecks = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
) => {
const checks = [
dataRetentionCheck,
];
const results = await Promise.all(checks.map((check) => check(config, formatMessage, options)));
return results;
};

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

@@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AdminConfig} from '@mattermost/types/config';
import {useIntl} from 'react-intl';
import {ItemModel, ItemStatus, Options} from '../dashboard.type';
import {ConsolePages, DocLinks} from 'utils/constants';
import {impactModifiers} from '../dashboard.data';
// import {Client4} from 'mattermost-redux/client';
// import {AnalyticsRow} from '@mattermost/types/admin';
import {ldapTest} from 'actions/admin_actions';
const usesLDAP = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
): Promise<ItemModel> => {
const testLdap = async (
config: Partial<AdminConfig>,
options: Options,
): Promise<ItemStatus> => {
let check = ItemStatus.INFO;
if (!options.isLicensed || !config.LdapSettings?.Enable) {
return check;
}
const onSuccess = ({status}: any) => {
if (status === 'OK') {
check = ItemStatus.OK;
}
};
await ldapTest(onSuccess);
return check;
};
// something feels flawed in this check.
const status = options.analytics?.TOTAL_USERS as number > 100 ? await testLdap(config, options) : ItemStatus.OK;
return {
id: 'ad-ldap',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.ease_of_management.ldap.title',
defaultMessage: 'AD/LDAP integration recommended',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.ease_of_management.ldap.description',
defaultMessage: 'You\'ve reached over 100 users! We recommend setting up AD/LDAP user authentication for easier onboarding as well as automated deactivations and role assignments.',
}),
...(options.isLicensed && !options.isStarterLicense ? {
configUrl: ConsolePages.AD_LDAP,
configText: formatMessage({id: 'admin.reporting.workspace_optimization.ease_of_management.ldap.cta', defaultMessage: 'Try AD/LDAP'}),
} : options.trialOrEnterpriseCtaConfig),
infoUrl: DocLinks.AD_LDAP,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'ad-ldap',
status,
scoreImpact: 22,
impactModifier: impactModifiers[status],
};
};
// // commented out for now.
// // @see discussion here: https://github.com/mattermost/mattermost-webapp/pull/9822#discussion_r806879385
// const fetchGuestAccounts = async (
// config: Partial<AdminConfig>,
// analytics: Record<string, number | AnalyticsRow[]> | undefined,
// ) => {
// if (config.TeamSettings?.EnableOpenServer && config.GuestAccountsSettings?.Enable) {
// let usersArray = await fetch(`${Client4.getBaseRoute()}/users/invalid_emails`).then((result) => result.json());
// // this setting is just a string with a list of domains, or an empty string
// if (config.GuestAccountsSettings?.RestrictCreationToDomains) {
// const domainList = config.GuestAccountsSettings?.RestrictCreationToDomains;
// usersArray = usersArray.filter(({email}: Record<string, unknown>) => domainList.includes((email as string).split('@')[1]));
// }
// // if guest accounts make up more than 5% of the user base show the info accordion
// if (analytics && usersArray.length > (analytics.totalUsers as number * 0.05)) {
// return ItemStatus.INFO;
// }
// }
// return ItemStatus.OK;
// };
// const guestAccounts = async (
// config: Partial<AdminConfig>,
// formatMessage: ReturnType<typeof useIntl>['formatMessage'],
// options: Options,
// ): Promise<ItemModel> => {
// const status = await fetchGuestAccounts(config, options.analytics);
// return {
// id: 'guest-accounts',
// title: formatMessage({
// id: 'admin.reporting.workspace_optimization.ease_of_management.guests_accounts.title',
// defaultMessage: 'Guest Accounts recommended',
// }),
// description: formatMessage({
// id: 'admin.reporting.workspace_optimization.ease_of_management.guests_accounts.description',
// defaultMessage: 'Several user accounts are using different domains than your Site URL. You can control user access to channels and teams with guest accounts. We recommend starting an Enterprise trial and enabling Guest Access.',
// }),
// ...options.trialOrEnterpriseCtaConfig,
// infoUrl: 'https://docs.mattermost.com/onboard/guest-accounts.html',
// infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
// telemetryAction: 'guest-accounts',
// status,
// scoreImpact: 6,
// impactModifier: impactModifiers[status],
// };
// };
export const runEaseOfUseChecks = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
): Promise<ItemModel[]> => {
const checks = [
usesLDAP,
// guestAccounts,
];
const results = await Promise.all(checks.map((check) => check(config, formatMessage, options)));
return results;
};

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

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AdminConfig} from '@mattermost/types/config';
import {ItemModel, ItemStatus, Options} from '../dashboard.type';
import {elasticsearchTest} from 'actions/admin_actions';
import {useIntl} from 'react-intl';
import {ConsolePages, DocLinks} from 'utils/constants';
import {impactModifiers} from '../dashboard.data';
const search = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
): Promise<ItemModel> => {
const testElasticsearch = async (
config: Partial<AdminConfig>,
options: Options,
) => {
let check = ItemStatus.INFO;
if (!options.isLicensed || !options.isEnterpriseLicense || !(config.ElasticsearchSettings?.EnableIndexing && config.ElasticsearchSettings?.EnableSearching)) {
return check;
}
const onSuccess = ({status}: any) => {
if (status === 'OK') {
check = ItemStatus.OK;
}
};
await elasticsearchTest(config, onSuccess);
return check;
};
const totalPosts = options.analytics?.TOTAL_POSTS as number;
const totalUsers = options.analytics?.TOTAL_USERS as number;
const status = totalPosts < 2_000_000 && totalUsers < 500 ? ItemStatus.OK : await testElasticsearch(config, options);
return {
id: 'search',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.performance.search.title',
defaultMessage: 'Search performance',
}),
description: formatMessage({
id: 'admin.reporting.workspace_optimization.performance.search.description',
defaultMessage: 'Your server has reached over 500 users and 2 million posts which can result in slow search performance. We recommend enabling Elasticsearch for better performance.',
}),
...(options.isLicensed && options.isEnterpriseLicense ? {
configUrl: ConsolePages.ELASTICSEARCH,
configText: formatMessage({id: 'admin.reporting.workspace_optimization.search.cta', defaultMessage: 'Try Elasticsearch'}),
} : options.trialOrEnterpriseCtaConfig),
infoUrl: DocLinks.ELASTICSEARCH,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'search-optimization',
status,
scoreImpact: 20,
impactModifier: impactModifiers[status],
};
};
export const runPerformanceChecks = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
) => {
const checks = [
search,
];
const results = await Promise.all(checks.map((check) => check(config, formatMessage, options)));
return results;
};

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

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from 'mattermost-redux/client';
import {ItemStatus, Options} from '../dashboard.type';
import {useIntl} from 'react-intl';
import {AdminConfig} from '@mattermost/types/config';
import {CloudLinks, DocLinks} from 'utils/constants';
import {impactModifiers} from '../dashboard.data';
const testServerVersion = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
) => {
const fetchVersion = async (
installedVersion: string,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
) => {
const result = await fetch(`${Client4.getBaseRoute()}/latest_version`).then((result) => result.json());
if (result.tag_name) {
const sanitizedVersion = result.tag_name.startsWith('v') ? result.tag_name.slice(1) : result.tag_name;
const newVersionParts = sanitizedVersion.split('.');
const installedVersionParts = installedVersion.split('.').slice(0, 3);
// quick general check if a newer version is available
let type = '';
let status: ItemStatus = ItemStatus.OK;
if (newVersionParts.join('') > installedVersionParts.join('')) {
// get correct values to be inserted into the accordion item
switch (true) {
case newVersionParts[0] > installedVersionParts[0]:
type = formatMessage({
id: 'admin.reporting.workspace_optimization.updates.server_version.update_type.major',
defaultMessage: 'Major',
});
status = ItemStatus.ERROR;
break;
case newVersionParts[1] > installedVersionParts[1]:
type = formatMessage({
id: 'admin.reporting.workspace_optimization.updates.server_version.update_type.minor',
defaultMessage: 'Minor',
});
status = ItemStatus.WARNING;
break;
case newVersionParts[2] > installedVersionParts[2]:
type = formatMessage({
id: 'admin.reporting.workspace_optimization.updates.server_version.update_type.patch',
defaultMessage: 'Patch',
});
status = ItemStatus.INFO;
break;
}
}
return {type, description: result.body, status};
}
return {type: '', description: '', status: ItemStatus.OK};
};
const serverVersion = await fetchVersion(options.installedVersion, formatMessage);
return {
id: 'server_version',
title: formatMessage({
id: 'admin.reporting.workspace_optimization.updates.server_version.status.title',
defaultMessage: '{type} version update available.',
}, {type: serverVersion.type}),
description: serverVersion.description,
configUrl: CloudLinks.DOWNLOAD_UPDATE,
configText: formatMessage({id: 'admin.reporting.workspace_optimization.updates.server_version.cta', defaultMessage: 'Download update'}),
infoUrl: DocLinks.UPGRADE_SERVER,
infoText: formatMessage({id: 'admin.reporting.workspace_optimization.cta.learnMore', defaultMessage: 'Learn more'}),
telemetryAction: 'server-version',
status: serverVersion.status,
scoreImpact: 15,
impactModifier: impactModifiers[serverVersion.status],
};
};
export const runUpdateChecks = async (
config: Partial<AdminConfig>,
formatMessage: ReturnType<typeof useIntl>['formatMessage'],
options: Options,
) => {
const checks = [
testServerVersion,
];
const results = await Promise.all(checks.map((check) => check(config, formatMessage, options)));
return results;
};

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

@@ -40,6 +40,7 @@ describe('components/AdvancedCreateComment', () => {
uploadsInProgress: [{}],
fileInfos: [{}, {}, {}],
},
isRemoteDraft: false,
enableAddButton: true,
ctrlSend: false,
latestPostId,
@@ -84,9 +85,10 @@ describe('components/AdvancedCreateComment', () => {
test('should match snapshot, empty comment', () => {
const draft = emptyDraft;
const isRemoteDraft = false;
const enableAddButton = false;
const ctrlSend = true;
const props = {...baseProps, draft, enableAddButton, ctrlSend};
const props = {...baseProps, draft, isRemoteDraft, enableAddButton, ctrlSend};
const wrapper = shallow(
<AdvancedCreateComment {...props}/>,
@@ -104,8 +106,9 @@ describe('components/AdvancedCreateComment', () => {
uploadsInProgress: [],
fileInfos: [],
};
const isRemoteDraft = false;
const ctrlSend = true;
const props = {...baseProps, ctrlSend, draft, clearCommentDraftUploads, onResetHistoryIndex, getChannelMemberCountsByGroup};
const props = {...baseProps, ctrlSend, draft, isRemoteDraft, clearCommentDraftUploads, onResetHistoryIndex, getChannelMemberCountsByGroup};
const wrapper = shallow(
<AdvancedCreateComment {...props}/>,

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

@@ -13,6 +13,7 @@ import * as GlobalActions from 'actions/global_actions';
import Constants, {AdvancedTextEditor as AdvancedTextEditorConst, Locations, ModalIdentifiers, Preferences} from 'utils/constants';
import {PreferenceType} from '@mattermost/types/preferences';
import * as Keyboard from 'utils/keyboard';
import * as UserAgent from 'utils/user_agent';
import * as Utils from 'utils/utils';
import {
@@ -73,6 +74,9 @@ type Props = {
// The current draft of the comment
draft: PostDraft;
// Data used for knowing if the draft came from a WS event
isRemoteDraft: boolean;
// Determines if the submit button should be rendered
enableAddButton?: boolean;
@@ -232,8 +236,14 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
const rootChanged = props.rootId !== state.rootId;
const messageInHistoryChanged = props.messageInHistory !== state.messageInHistory;
if (rootChanged || messageInHistoryChanged || props.draft.remote) {
updatedState = {...updatedState, draft: {...props.draft, uploadsInProgress: rootChanged ? [] : props.draft.uploadsInProgress}};
if (rootChanged || messageInHistoryChanged || (props.isRemoteDraft && props.draft.message !== state.draft?.message)) {
updatedState = {
...updatedState,
draft: {
...props.draft,
uploadsInProgress: rootChanged ? [] : props.draft.uploadsInProgress,
},
};
}
return updatedState;
@@ -251,6 +261,7 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
serverError: null,
showFormat: false,
isFormattingBarHidden: props.isFormattingBarHidden,
caretPosition: props.draft.caretPosition,
};
this.textboxRef = React.createRef();
@@ -342,7 +353,6 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
const updatedDraft = {
...this.state.draft,
show: !isDraftEmpty(this.state.draft),
remote: false,
} as PostDraft;
this.props.onUpdateCommentDraft(updatedDraft, true);
@@ -355,7 +365,6 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
draft: {
...prev.draft,
show: !isDraftEmpty(prev.draft),
remote: false,
} as PostDraft,
};
}
@@ -819,11 +828,11 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
handleKeyDown = (e: React.KeyboardEvent<TextboxElement>) => {
const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey;
const lastMessageReactionKeyCombo = ctrlOrMetaKeyPressed && e.shiftKey && Utils.isKeyPressed(e, KeyCodes.BACK_SLASH);
const lastMessageReactionKeyCombo = ctrlOrMetaKeyPressed && e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH);
const ctrlKeyCombo = Utils.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey;
const ctrlAltCombo = Utils.cmdOrCtrlPressed(e, true) && e.altKey;
const shiftAltCombo = !Utils.cmdOrCtrlPressed(e) && e.shiftKey && e.altKey;
const ctrlKeyCombo = Keyboard.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey;
const ctrlAltCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.altKey;
const shiftAltCombo = !Keyboard.cmdOrCtrlPressed(e) && e.shiftKey && e.altKey;
// listen for line break key combo and insert new line character
if (Utils.isUnhandledLineBreakKeyCombo(e)) {
@@ -838,7 +847,7 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
if (
(this.props.ctrlSend || this.props.codeBlockOnCtrlEnter) &&
Utils.isKeyPressed(e, KeyCodes.ENTER) &&
Keyboard.isKeyPressed(e, KeyCodes.ENTER) &&
(e.ctrlKey || e.metaKey)
) {
this.setShowPreview(false);
@@ -849,7 +858,7 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
const draft = this.state.draft!;
const {message} = draft;
if (Utils.isKeyPressed(e, KeyCodes.ESCAPE)) {
if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE)) {
this.textboxRef.current?.blur();
}
@@ -858,7 +867,7 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
!e.metaKey &&
!e.altKey &&
!e.shiftKey &&
Utils.isKeyPressed(e, KeyCodes.UP) &&
Keyboard.isKeyPressed(e, KeyCodes.UP) &&
message === ''
) {
e.preventDefault();
@@ -879,13 +888,13 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
} = e.target as TextboxElement;
if (ctrlKeyCombo) {
if (Utils.isKeyPressed(e, KeyCodes.UP)) {
if (Keyboard.isKeyPressed(e, KeyCodes.UP)) {
e.preventDefault();
this.props.onMoveHistoryIndexBack();
} else if (Utils.isKeyPressed(e, KeyCodes.DOWN)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.DOWN)) {
e.preventDefault();
this.props.onMoveHistoryIndexForward();
} else if (Utils.isKeyPressed(e, KeyCodes.B)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.B)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
@@ -894,7 +903,7 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
selectionEnd,
message: value,
});
} else if (Utils.isKeyPressed(e, KeyCodes.I)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.I)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
@@ -905,7 +914,7 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
});
}
} else if (ctrlAltCombo) {
if (Utils.isKeyPressed(e, KeyCodes.K)) {
if (Keyboard.isKeyPressed(e, KeyCodes.K)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
@@ -914,7 +923,7 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
selectionEnd,
message: value,
});
} else if (Utils.isKeyPressed(e, KeyCodes.C)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.C)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
@@ -923,21 +932,21 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
selectionEnd,
message: value,
});
} else if (Utils.isKeyPressed(e, KeyCodes.E)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.E)) {
e.stopPropagation();
e.preventDefault();
this.toggleEmojiPicker();
} else if (Utils.isKeyPressed(e, KeyCodes.T)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.T)) {
e.stopPropagation();
e.preventDefault();
this.toggleAdvanceTextEditor();
} else if (Utils.isKeyPressed(e, KeyCodes.P) && draft.message.length) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.P) && draft.message.length) {
e.stopPropagation();
e.preventDefault();
this.setShowPreview(!this.props.shouldShowPreview);
}
} else if (shiftAltCombo) {
if (Utils.isKeyPressed(e, KeyCodes.X)) {
if (Keyboard.isKeyPressed(e, KeyCodes.X)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
@@ -946,7 +955,7 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
selectionEnd,
message: value,
});
} else if (Utils.isKeyPressed(e, KeyCodes.SEVEN)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.SEVEN)) {
e.preventDefault();
this.applyMarkdown({
markdownMode: 'ol',
@@ -954,7 +963,7 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
selectionEnd,
message: value,
});
} else if (Utils.isKeyPressed(e, KeyCodes.EIGHT)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.EIGHT)) {
e.preventDefault();
this.applyMarkdown({
markdownMode: 'ul',
@@ -962,7 +971,7 @@ class AdvancedCreateComment extends React.PureComponent<Props, State> {
selectionEnd,
message: value,
});
} else if (Utils.isKeyPressed(e, KeyCodes.NINE)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.NINE)) {
e.preventDefault();
this.applyMarkdown({
markdownMode: 'quote',

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

@@ -64,6 +64,7 @@ function makeMapStateToProps() {
const err = state.requests.posts.createPost.error || {};
const draft = getPostDraft(state, StoragePrefixes.COMMENT_DRAFT, ownProps.rootId);
const isRemoteDraft = state.views.drafts.remotes[`${StoragePrefixes.COMMENT_DRAFT}${ownProps.rootId}`] || false;
const channelMembersCount = getAllChannelStats(state)[ownProps.channelId] ? getAllChannelStats(state)[ownProps.channelId].member_count : 1;
const messageInHistory = getMessageInHistoryItem(state);
@@ -91,6 +92,7 @@ function makeMapStateToProps() {
return {
currentTeamId,
draft,
isRemoteDraft,
messageInHistory,
channelMembersCount,
currentUserId,
@@ -121,11 +123,11 @@ function makeMapStateToProps() {
}
function makeOnUpdateCommentDraft(rootId: string, channelId: string) {
return (draft?: PostDraft, save = false) => updateCommentDraft(rootId, draft ? {...draft, channelId, remote: false} : draft, save);
return (draft?: PostDraft, save = false) => updateCommentDraft(rootId, draft ? {...draft, channelId} : draft, save);
}
function makeUpdateCommentDraftWithRootId(channelId: string) {
return (rootId: string, draft?: PostDraft, save = false) => updateCommentDraft(rootId, draft ? {...draft, channelId, remote: false} : draft, save);
return (rootId: string, draft?: PostDraft, save = false) => updateCommentDraft(rootId, draft ? {...draft, channelId} : draft, save);
}
type Actions = {

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

@@ -115,6 +115,7 @@ function advancedCreatePost({
fullWidthTextBox={fullWidthTextBox}
currentChannelMembersCount={currentChannelMembersCount}
draft={draft}
isRemoteDraft={false}
recentPostIdInChannel={recentPostIdInChannel}
latestReplyablePostId={latestReplyablePostId}
locale={locale}

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

@@ -22,6 +22,7 @@ import Constants, {
Preferences,
AdvancedTextEditor as AdvancedTextEditorConst,
} from 'utils/constants';
import * as Keyboard from 'utils/keyboard';
import {
containsAtChannel,
specialMentionsInText,
@@ -34,7 +35,6 @@ import {
} from 'utils/post_utils';
import {getTable, hasHtmlLink, formatMarkdownMessage, formatGithubCodePaste, isGitHubCodeBlock} from 'utils/paste';
import * as UserAgent from 'utils/user_agent';
import {isMac} from 'utils/utils';
import * as Utils from 'utils/utils';
import EmojiMap from 'utils/emoji_map';
import {applyMarkdown, ApplyMarkdownOptions} from 'utils/markdown/apply_markdown';
@@ -124,6 +124,9 @@ type Props = {
// Data used for populating message state from previous draft
draft: PostDraft;
// Data used for knowing if the draft came from a WS event
isRemoteDraft: boolean;
// Data used dispatching handleViewAction ex: edit post
latestReplyablePostId?: string;
locale: string;
@@ -279,7 +282,7 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
};
if (
props.currentChannel.id !== state.currentChannel.id ||
(props.draft.remote && props.draft.message !== state.message)
(props.isRemoteDraft && props.draft.message !== state.message)
) {
updatedState = {
...updatedState,
@@ -294,8 +297,8 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
message: this.props.draft.message,
caretPosition: this.props.draft.message.length,
message: props.draft.message,
caretPosition: props.draft.message.length,
submitting: false,
showEmojiPicker: false,
uploadsProgressPercent: {},
@@ -387,7 +390,6 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
this.draftsForChannel[channelId] = {
...draft,
show: !isDraftEmpty(draft),
remote: false,
} as PostDraft;
}
}
@@ -1096,7 +1098,7 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
documentKeyHandler = (e: KeyboardEvent) => {
const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey;
const lastMessageReactionKeyCombo = ctrlOrMetaKeyPressed && e.shiftKey && Utils.isKeyPressed(e, KeyCodes.BACK_SLASH);
const lastMessageReactionKeyCombo = ctrlOrMetaKeyPressed && e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH);
if (lastMessageReactionKeyCombo) {
this.reactToLastMessage(e);
return;
@@ -1134,12 +1136,12 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey;
const ctrlEnterKeyCombo = (this.props.ctrlSend || this.props.codeBlockOnCtrlEnter) &&
Utils.isKeyPressed(e, KeyCodes.ENTER) &&
Keyboard.isKeyPressed(e, KeyCodes.ENTER) &&
ctrlOrMetaKeyPressed;
const ctrlKeyCombo = Utils.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey;
const ctrlAltCombo = Utils.cmdOrCtrlPressed(e, true) && e.altKey;
const shiftAltCombo = !Utils.cmdOrCtrlPressed(e) && e.shiftKey && e.altKey;
const ctrlKeyCombo = Keyboard.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey;
const ctrlAltCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.altKey;
const shiftAltCombo = !Keyboard.cmdOrCtrlPressed(e) && e.shiftKey && e.altKey;
// listen for line break key combo and insert new line character
if (Utils.isUnhandledLineBreakKeyCombo(e)) {
@@ -1155,7 +1157,7 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
const {message} = this.state;
if (Utils.isKeyPressed(e, KeyCodes.ESCAPE)) {
if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE)) {
this.textboxRef.current?.blur();
}
@@ -1164,7 +1166,7 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
!e.metaKey &&
!e.altKey &&
!e.shiftKey &&
Utils.isKeyPressed(e, KeyCodes.UP) &&
Keyboard.isKeyPressed(e, KeyCodes.UP) &&
message === ''
) {
e.preventDefault();
@@ -1182,15 +1184,15 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
} = e.target as TextboxElement;
if (ctrlKeyCombo) {
if (draftMessageIsEmpty && Utils.isKeyPressed(e, KeyCodes.UP)) {
if (draftMessageIsEmpty && Keyboard.isKeyPressed(e, KeyCodes.UP)) {
e.stopPropagation();
e.preventDefault();
this.loadPrevMessage(e);
} else if (draftMessageIsEmpty && Utils.isKeyPressed(e, KeyCodes.DOWN)) {
} else if (draftMessageIsEmpty && Keyboard.isKeyPressed(e, KeyCodes.DOWN)) {
e.stopPropagation();
e.preventDefault();
this.loadNextMessage(e);
} else if (Utils.isKeyPressed(e, KeyCodes.B)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.B)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
@@ -1199,7 +1201,7 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
selectionEnd,
message: value,
});
} else if (Utils.isKeyPressed(e, KeyCodes.I)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.I)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
@@ -1210,7 +1212,7 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
});
}
} else if (ctrlAltCombo) {
if (Utils.isKeyPressed(e, KeyCodes.K)) {
if (Keyboard.isKeyPressed(e, KeyCodes.K)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
@@ -1219,7 +1221,7 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
selectionEnd,
message: value,
});
} else if (Utils.isKeyPressed(e, KeyCodes.C)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.C)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
@@ -1228,21 +1230,21 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
selectionEnd,
message: value,
});
} else if (Utils.isKeyPressed(e, KeyCodes.E)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.E)) {
e.stopPropagation();
e.preventDefault();
this.toggleEmojiPicker();
} else if (Utils.isKeyPressed(e, KeyCodes.T)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.T)) {
e.stopPropagation();
e.preventDefault();
this.toggleAdvanceTextEditor();
} else if (Utils.isKeyPressed(e, KeyCodes.P) && message.length) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.P) && message.length) {
e.stopPropagation();
e.preventDefault();
this.setShowPreview(!this.props.shouldShowPreview);
}
} else if (shiftAltCombo) {
if (Utils.isKeyPressed(e, KeyCodes.X)) {
if (Keyboard.isKeyPressed(e, KeyCodes.X)) {
e.stopPropagation();
e.preventDefault();
this.applyMarkdown({
@@ -1251,7 +1253,7 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
selectionEnd,
message: value,
});
} else if (Utils.isKeyPressed(e, KeyCodes.SEVEN)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.SEVEN)) {
e.preventDefault();
this.applyMarkdown({
markdownMode: 'ol',
@@ -1259,7 +1261,7 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
selectionEnd,
message: value,
});
} else if (Utils.isKeyPressed(e, KeyCodes.EIGHT)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.EIGHT)) {
e.preventDefault();
this.applyMarkdown({
markdownMode: 'ul',
@@ -1267,7 +1269,7 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
selectionEnd,
message: value,
});
} else if (Utils.isKeyPressed(e, KeyCodes.NINE)) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.NINE)) {
e.preventDefault();
this.applyMarkdown({
markdownMode: 'quote',
@@ -1277,21 +1279,21 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
});
}
}
const upKeyOnly = !ctrlOrMetaKeyPressed && !e.altKey && !e.shiftKey && Utils.isKeyPressed(e, KeyCodes.UP);
const shiftUpKeyCombo = !ctrlOrMetaKeyPressed && !e.altKey && e.shiftKey && Utils.isKeyPressed(e, KeyCodes.UP);
const ctrlShiftCombo = Utils.cmdOrCtrlPressed(e, true) && e.shiftKey;
const upKeyOnly = !ctrlOrMetaKeyPressed && !e.altKey && !e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP);
const shiftUpKeyCombo = !ctrlOrMetaKeyPressed && !e.altKey && e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP);
const ctrlShiftCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.shiftKey;
if (upKeyOnly && messageIsEmpty) {
this.editLastPost(e);
} else if (shiftUpKeyCombo && messageIsEmpty) {
this.replyToLastPost(e);
} else if (ctrlShiftCombo && Utils.isKeyPressed(e, KeyCodes.E)) {
} else if (ctrlShiftCombo && Keyboard.isKeyPressed(e, KeyCodes.E)) {
e.stopPropagation();
e.preventDefault();
this.toggleEmojiPicker();
} else if (((isMac() && ctrlShiftCombo) || (!isMac() && ctrlAltCombo)) && Utils.isKeyPressed(e, KeyCodes.P) && this.state.message.length) {
} else if (((UserAgent.isMac() && ctrlShiftCombo) || (!UserAgent.isMac() && ctrlAltCombo)) && Keyboard.isKeyPressed(e, KeyCodes.P) && this.state.message.length) {
this.setShowPreview(!this.props.shouldShowPreview);
} else if (ctrlAltCombo && Utils.isKeyPressed(e, KeyCodes.T)) {
} else if (ctrlAltCombo && Keyboard.isKeyPressed(e, KeyCodes.T)) {
this.toggleAdvanceTextEditor();
}
};

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

@@ -77,6 +77,7 @@ function makeMapStateToProps() {
const currentChannel = getCurrentChannel(state) || {};
const currentChannelTeammateUsername = getUser(state, currentChannel.teammate_id || '')?.username;
const draft = getChannelDraft(state, currentChannel.id);
const isRemoteDraft = state.views.drafts.remotes[`${StoragePrefixes.DRAFT}${currentChannel.id}`] || false;
const latestReplyablePostId = getLatestReplyablePostId(state);
const currentChannelMembersCount = getCurrentChannelStats(state) ? getCurrentChannelStats(state).member_count : 1;
const enableEmojiPicker = config.EnableEmojiPicker === 'true';
@@ -117,6 +118,7 @@ function makeMapStateToProps() {
showSendTutorialTip,
messageInHistoryItem: getMessageInHistoryItem(state),
draft,
isRemoteDraft,
latestReplyablePostId,
locale: getCurrentLocale(state),
currentUsersLatestPost: getCurrentUsersLatestPost(state, ''),
@@ -181,12 +183,7 @@ function setDraft(key: string, value: PostDraft, draftChannelId: string, save =
const channelId = draftChannelId || getCurrentChannelId(getState());
let updatedValue = null;
if (value) {
updatedValue = {...value};
updatedValue = {
...value,
channelId,
remote: false,
};
updatedValue = {...value, channelId};
}
if (updatedValue) {
return dispatch(updateDraft(key, updatedValue, '', save));

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

@@ -21,6 +21,7 @@ import './alert_banner.scss';
export type ModeType = 'danger' | 'warning' | 'info' | 'success';
export type AlertBannerProps = {
id?: string;
mode: ModeType;
title?: React.ReactNode;
message?: React.ReactNode;
@@ -35,6 +36,7 @@ export type AlertBannerProps = {
}
const AlertBanner = ({
id,
mode,
title,
message,
@@ -70,6 +72,7 @@ const AlertBanner = ({
return (
<div
data-testid={id}
className={classNames(
'AlertBanner',
mode,

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

@@ -4,6 +4,7 @@
import React from 'react';
import {ToYearlyNudgeBannerDismissable} from 'components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner';
import {ToPaidPlanBannerDismissable} from 'components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner';
import {ClientLicense, ClientConfig, WarnMetricStatus} from '@mattermost/types/config';
import withGetCloudSubscription from '../common/hocs/cloud/with_get_cloud_subscription';
@@ -72,6 +73,7 @@ class AnnouncementBarController extends React.PureComponent<Props> {
let cloudDelinquencyAnnouncementBar = null;
let notifyAdminDowngradeDelinquencyBar = null;
let toYearlyNudgeBannerDismissable = null;
let toPaidPlanNudgeBannerDismissable = null;
if (this.props.license?.Cloud === 'true') {
paymentAnnouncementBar = (
<PaymentAnnouncementBar/>
@@ -89,6 +91,7 @@ class AnnouncementBarController extends React.PureComponent<Props> {
<NotifyAdminDowngradeDelinquencyBar/>
);
toYearlyNudgeBannerDismissable = (<ToYearlyNudgeBannerDismissable/>);
toPaidPlanNudgeBannerDismissable = (<ToPaidPlanBannerDismissable/>);
}
let autoStartTrialModal = null;
@@ -108,6 +111,7 @@ class AnnouncementBarController extends React.PureComponent<Props> {
{cloudDelinquencyAnnouncementBar}
{notifyAdminDowngradeDelinquencyBar}
{toYearlyNudgeBannerDismissable}
{toPaidPlanNudgeBannerDismissable}
{this.props.license?.Cloud !== 'true' && <OverageUsersBanner/>}
{autoStartTrialModal}
<ShowThreeDaysLeftTrialModal/>

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

@@ -18,6 +18,8 @@ import PricingModal from 'components/pricing_modal';
import {ModalData} from 'types/actions';
import {AlertCircleOutlineIcon, AlertOutlineIcon} from '@mattermost/compass-icons/components';
import {
Preferences,
CloudBanners,
@@ -38,6 +40,7 @@ type Props = {
daysLeftOnTrial: number;
isCloud: boolean;
subscription?: Subscription;
reverseTrial: boolean;
actions: {
savePreferences: (userId: string, preferences: PreferenceType[]) => void;
getCloudSubscription: () => void;
@@ -132,15 +135,34 @@ class CloudTrialAnnouncementBar extends React.PureComponent<Props> {
return null;
}
const trialMoreThan3DaysMsg = (
let trialMoreThan7DaysMsg = (
<FormattedMessage
id='admin.billing.subscription.cloudTrial.moreThan3Days'
id='admin.billing.subscription.cloudTrial.daysLeft'
defaultMessage='Your trial has started! There are {daysLeftOnTrial} days left'
values={{daysLeftOnTrial}}
/>
);
const trialLessThan3DaysMsg = (
let modalButtonText = t('admin.billing.subscription.cloudTrial.subscribeButton');
let modalButtonDefaultText = 'Upgrade Now';
if (this.props.reverseTrial) {
modalButtonText = t('admin.billing.subscription.cloudReverseTrial.subscribeButton');
modalButtonDefaultText = 'Review your options';
}
if (this.props.reverseTrial) {
const trialEnd = getLocaleDateFromUTC((this.props.subscription?.trial_end_at as number / 1000), 'MMMM Do');
trialMoreThan7DaysMsg = (
<FormattedMessage
id='admin.billing.subscription.cloudTrial.moreThan3Days'
defaultMessage='Your 30 day Enterprise trial has started! Purchase before {trialEnd} to keep your workspace.'
values={{trialEnd}}
/>
);
}
let trialLessThan7DaysMsg = (
<FormattedMessage
id='admin.billing.subscription.cloudTrial.daysLeftOnTrial'
defaultMessage='There are {daysLeftOnTrial} days left on your free trial'
@@ -148,10 +170,20 @@ class CloudTrialAnnouncementBar extends React.PureComponent<Props> {
/>
);
const userEndTrialDate = getLocaleDateFromUTC((this.props.subscription?.trial_end_at as number / 1000), 'MMMM Do YYYY');
const userEndTrialHour = getLocaleDateFromUTC((this.props.subscription?.trial_end_at as number / 1000), 'HH:mm:ss', this.props.currentUser.timezone?.automaticTimezone as string);
if (this.props.reverseTrial) {
trialLessThan7DaysMsg = (
<FormattedMessage
id='admin.billing.subscription.cloudReverseTrial.daysLeftOnTrial'
defaultMessage='{daysLeftOnTrial} days left on your trial. Purchase a plan or contact sales to keep your workspace.'
values={{daysLeftOnTrial}}
/>
);
}
const trialLastDaysMsg = (
const userEndTrialDate = getLocaleDateFromUTC((this.props.subscription?.trial_end_at as number / 1000), 'MMMM Do YYYY');
const userEndTrialHour = getLocaleDateFromUTC((this.props.subscription?.trial_end_at as number / 1000), 'HH:mm', this.props.currentUser.timezone?.automaticTimezone as string);
let trialLastDaysMsg = (
<FormattedMessage
id='admin.billing.subscription.cloudTrial.lastDay'
defaultMessage='This is the last day of your free trial. Your access will expire on {userEndTrialDate} at {userEndTrialHour}.'
@@ -159,21 +191,28 @@ class CloudTrialAnnouncementBar extends React.PureComponent<Props> {
/>
);
if (this.props.reverseTrial) {
trialLastDaysMsg = (
<FormattedMessage
id='admin.billing.subscription.cloudReverseTrial.lastDay'
defaultMessage='This is the last day of your trial. Purchase a plan before {userEndTrialHour} or contact sales'
values={{userEndTrialHour}}
/>
);
}
let bannerMessage;
let icon;
switch (daysLeftOnTrial) {
case TrialPeriodDays.TRIAL_WARNING_THRESHOLD:
case TrialPeriodDays.TRIAL_2_DAYS:
bannerMessage = trialLessThan3DaysMsg;
break;
case TrialPeriodDays.TRIAL_1_DAY:
case TrialPeriodDays.TRIAL_0_DAYS:
if (daysLeftOnTrial >= TrialPeriodDays.TRIAL_2_DAYS && daysLeftOnTrial <= TrialPeriodDays.TRIAL_WARNING_THRESHOLD) {
bannerMessage = trialLessThan7DaysMsg;
icon = <AlertCircleOutlineIcon size={18}/>;
} else if (daysLeftOnTrial <= TrialPeriodDays.TRIAL_1_DAY && daysLeftOnTrial >= TrialPeriodDays.TRIAL_0_DAYS) {
bannerMessage = trialLastDaysMsg;
break;
default:
bannerMessage = trialMoreThan3DaysMsg;
icon = <i className='icon-check-outline-circle'/>;
break;
icon = <AlertOutlineIcon size={18}/>;
} else {
bannerMessage = trialMoreThan7DaysMsg;
icon = <AlertCircleOutlineIcon size={18}/>;
}
const dismissable = this.isDismissable();
@@ -184,8 +223,8 @@ class CloudTrialAnnouncementBar extends React.PureComponent<Props> {
showCloseButton={dismissable}
handleClose={this.handleClose}
onButtonClick={this.showModal}
modalButtonText={t('admin.billing.subscription.cloudTrial.subscribeButton')}
modalButtonDefaultText={'Upgrade Now'}
modalButtonText={modalButtonText}
modalButtonDefaultText={modalButtonDefaultText}
message={bannerMessage}
showLinkAsButton={true}
icon={icon}

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

@@ -21,6 +21,7 @@ import {Preferences, TrialPeriodDays} from 'utils/constants';
import {getRemainingDaysFromFutureTimestamp} from 'utils/utils';
import CloudTrialAnnouncementBar from './cloud_trial_announcement_bar';
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
function mapStateToProps(state: GlobalState) {
const getCategory = makeGetCategory();
@@ -29,6 +30,7 @@ function mapStateToProps(state: GlobalState) {
const isCloud = getLicense(state).Cloud === 'true';
let isFreeTrial = false;
let daysLeftOnTrial = 0;
const config = getConfig(state);
if (isCloud && subscription?.is_free_trial === 'true') {
isFreeTrial = true;
@@ -46,6 +48,7 @@ function mapStateToProps(state: GlobalState) {
isCloud,
subscription,
preferences: getCategory(state, Preferences.CLOUD_TRIAL_BANNER),
reverseTrial: Boolean(config.FeatureFlags?.CloudReverseTrial),
};
}

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

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {ReactNode} from 'react';
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl';
import {Link} from 'react-router-dom';
@@ -395,7 +395,7 @@ const ConfigurationAnnouncementBar = (props: Props) => {
defaultMessage = 'Please configure your <linkSite>site URL</linkSite> on the <linkConsole>System Console</linkConsole>.';
}
const values = {
const values: Record<string, ReactNode> = {
linkSite: (msg: string) => (
<ExternalLink
href={props.siteURL}

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

@@ -19,6 +19,7 @@ import ToggleModalButton from 'components/toggle_modal_button';
import {trackEvent} from 'actions/telemetry_actions.jsx';
type Props = {
id?: string;
showCloseButton: boolean;
color: string;
textColor: string;
@@ -172,6 +173,7 @@ export default class AnnouncementBar extends React.PureComponent<Props, State> {
style={barStyle}
// eslint-disable-next-line react/no-unknown-property
css={{gridArea: 'announcement'}}
data-testid={this.props.id}
>
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}

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

@@ -5,15 +5,13 @@ 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'; // eslint-disable-line no-restricted-imports
import {openModal} from 'actions/views/modals';
import MarketplaceModal from 'components/plugin_marketplace/marketplace_modal';
import OverlayTrigger from 'components/overlay_trigger';
import {Constants, ModalIdentifiers} from 'utils/constants';
import {ViewGridPlusOutlineIcon} from '@mattermost/compass-icons/components';
const AppBarMarketplace = () => {
const {formatMessage} = useIntl();
@@ -48,10 +46,7 @@ const AppBarMarketplace = () => {
aria-label={label}
onClick={handleOpenMarketplace}
>
<Icon
size={16}
glyph={'view-grid-plus-outline'}
/>
<ViewGridPlusOutlineIcon size={18}/>
</button>
</OverlayTrigger>
);

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

@@ -234,7 +234,7 @@ export class AppsForm extends React.PureComponent<Props, State> {
const errorResponse = res.error;
const errMsg = errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error.',
defaultMessage: 'Unknown error occurred.',
});
this.setState({
fieldErrors: {
@@ -256,7 +256,7 @@ export class AppsForm extends React.PureComponent<Props, State> {
case AppCallResponseTypes.NAVIGATE: {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.unexpected_type',
defaultMessage: 'App response type was not expected. Response type: {type}.',
defaultMessage: 'App response type was not expected. Response type: {type}',
}, {
type: callResp.type,
},
@@ -338,7 +338,7 @@ export class AppsForm extends React.PureComponent<Props, State> {
case AppCallResponseTypes.NAVIGATE:
this.updateErrors([], undefined, this.props.intl.formatMessage({
id: 'apps.error.responses.unexpected_type',
defaultMessage: 'App response type was not expected. Response type: {type}.',
defaultMessage: 'App response type was not expected. Response type: {type}',
}, {
type: callResponse.type,
}));

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

@@ -48,7 +48,7 @@ class AppsFormContainer extends React.PureComponent<Props, State> {
};
const {form} = this.state;
if (!form) {
const errMsg = this.props.intl.formatMessage({id: 'apps.error.form.no_form', defaultMessage: '`form` is not defined'});
const errMsg = this.props.intl.formatMessage({id: 'apps.error.form.no_form', defaultMessage: '`form` is not defined.'});
return {error: makeCallErrorResponse(makeErrorMsg(errMsg))};
}
if (!form.submit) {
@@ -97,7 +97,7 @@ class AppsFormContainer extends React.PureComponent<Props, State> {
refreshOnSelect = async (field: AppField, values: AppFormValues): Promise<DoAppCallResult<FormResponseData>> => {
const makeErrMsg = (message: string) => this.props.intl.formatMessage(
{
id: 'apps.error.form.refresh',
id: 'apps.error.form.update',
defaultMessage: 'There has been an error updating the modal. Contact the app developer. Details: {details}',
},
{details: message},
@@ -144,7 +144,7 @@ class AppsFormContainer extends React.PureComponent<Props, State> {
case AppCallResponseTypes.NAVIGATE:
return {error: makeCallErrorResponse(makeErrMsg(this.props.intl.formatMessage({
id: 'apps.error.responses.unexpected_type',
defaultMessage: 'App response type was not expected. Response type: {type}.',
defaultMessage: 'App response type was not expected. Response type: {type}',
}, {
type: callResp.type,
},

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

@@ -12,9 +12,10 @@ import {Group} from '@mattermost/types/groups';
import ProfilePopover from 'components/profile_popover';
import {popOverOverlayPosition} from 'utils/position_utils';
import {isKeyPressed} from 'utils/keyboard';
import {getUserOrGroupFromMentionName} from 'utils/post_utils';
import Constants from 'utils/constants';
import {getViewportSize, isKeyPressed} from 'utils/utils';
import {getViewportSize} from 'utils/utils';
import AtMentionGroup from 'components/at_mention/at_mention_group';

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

@@ -12,8 +12,9 @@ import ProfilePopover from 'components/profile_popover';
import UserGroupPopover from 'components/user_group_popover';
import Constants, {A11yCustomEventTypes, A11yFocusEventDetail} from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {popOverOverlayPosition} from 'utils/position_utils';
import {getViewportSize, isKeyPressed} from 'utils/utils';
import {getViewportSize} from 'utils/utils';
import {MAX_LIST_HEIGHT, getListHeight, VIEWPORT_SCALE_FACTOR} from 'components/user_group_popover/group_member_list/group_member_list';

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

@@ -145,7 +145,7 @@ class ChannelGroupsManageModal extends React.PureComponent<Props> {
const {formatMessage} = this.props.intl;
return (
<ListModal
titleText={formatMessage({id: 'groups', defaultMessage: '{channel} Groups'}, {channel: this.props.channel.display_name})}
titleText={formatMessage({id: 'channel_groups', defaultMessage: '{channel} Groups'}, {channel: this.props.channel.display_name})}
searchPlaceholderText={formatMessage({id: 'manage_channel_groups_modal.search_placeholder', defaultMessage: 'Search groups'})}
renderRow={this.renderRow}
loadItems={this.loadItems}

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

@@ -6,7 +6,7 @@ import {FormattedMessage} from 'react-intl';
import styled from 'styled-components';
import Constants from 'utils/constants';
import {isKeyPressed} from 'utils/utils';
import {isKeyPressed} from 'utils/keyboard';
const Title = styled.div`
flex:1;

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

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import React, {useEffect, useState, ReactNode} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
@@ -14,8 +14,9 @@ import {trackEvent} from 'actions/telemetry_actions';
import {openModal, closeModal} from 'actions/views/modals';
import TrialBenefitsModal from 'components/trial_benefits_modal/trial_benefits_modal';
import ExternalLink from 'components/external_link';
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
import {ModalIdentifiers, TELEMETRY_CATEGORIES, LicenseLinks} from 'utils/constants';
import RequestBusinessEmailModal from './request_business_email_modal';
import './cloud_start_trial_btn.scss';
@@ -129,7 +130,7 @@ const CloudStartTrialButton = ({
}));
};
const btnText = (status: TrialLoadStatus): string => {
const btnText = (status: TrialLoadStatus) => {
switch (status) {
case TrialLoadStatus.Started:
return formatMessage({id: 'start_cloud_trial.modal.gettingTrial', defaultMessage: 'Getting Trial...'});
@@ -138,7 +139,22 @@ const CloudStartTrialButton = ({
case TrialLoadStatus.Failed:
return formatMessage({id: 'start_cloud_trial.modal.failed', defaultMessage: 'Failed'});
case TrialLoadStatus.Embargoed:
return formatMessage({id: 'admin.license.trial-request.embargoed'});
return formatMessage<ReactNode>(
{
id: 'admin.license.trial-request.embargoed',
defaultMessage: 'We were unable to process the request due to limitations for embargoed countries. <link>Learn more in our documentation</link>, or reach out to legal@mattermost.com for questions around export limitations.',
},
{
link: (text: string) => (
<ExternalLink
location='trial_banner'
href={LicenseLinks.EMBARGOED_COUNTRIES}
>
{text}
</ExternalLink>
),
},
);
default:
return message;
}

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

@@ -29,9 +29,11 @@ exports[`components/pricing_modal/downgrade_team_removal_modal matches snapshot
forwardedRef={null}
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
@@ -58,6 +60,7 @@ exports[`components/pricing_modal/downgrade_team_removal_modal matches snapshot
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,

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

@@ -29,9 +29,11 @@ exports[`components/pricing_modal/downgrade_team_removal_modal matches snapshot
forwardedRef={null}
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
@@ -58,6 +60,7 @@ exports[`components/pricing_modal/downgrade_team_removal_modal matches snapshot
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,

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

@@ -4,10 +4,12 @@ exports[`codeBlock should render html code block with proper indentation after s
<IntlProvider
defaultFormats={Object {}}
defaultLocale="en"
fallbackOnEmptyString={true}
formats={Object {}}
locale="en"
messages={Object {}}
onError={[Function]}
onWarn={[Function]}
textComponent={Symbol(react.fragment)}
>
<CodeBlock
@@ -68,9 +70,11 @@ exports[`codeBlock should render html code block with proper indentation after s
id="copyButton"
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
@@ -97,6 +101,7 @@ exports[`codeBlock should render html code block with proper indentation after s
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": Symbol(react.fragment),
"timeZone": undefined,
"wrapRichTextChunksInFragment": undefined,
@@ -228,10 +233,12 @@ exports[`codeBlock should render typescript code block after syntax highlighting
<IntlProvider
defaultFormats={Object {}}
defaultLocale="en"
fallbackOnEmptyString={true}
formats={Object {}}
locale="en"
messages={Object {}}
onError={[Function]}
onWarn={[Function]}
textComponent={Symbol(react.fragment)}
>
<CodeBlock
@@ -292,9 +299,11 @@ const myFunction = () => {
id="copyButton"
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
@@ -321,6 +330,7 @@ const myFunction = () => {
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": Symbol(react.fragment),
"timeZone": undefined,
"wrapRichTextChunksInFragment": undefined,
@@ -452,10 +462,12 @@ exports[`codeBlock should render unknown language after syntax highlighting 1`]
<IntlProvider
defaultFormats={Object {}}
defaultLocale="en"
fallbackOnEmptyString={true}
formats={Object {}}
locale="en"
messages={Object {}}
onError={[Function]}
onWarn={[Function]}
textComponent={Symbol(react.fragment)}
>
<CodeBlock
@@ -514,9 +526,11 @@ it shouldn't highlight, it's just garbage
id="copyButton"
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
@@ -543,6 +557,7 @@ it shouldn't highlight, it's just garbage
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": Symbol(react.fragment),
"timeZone": undefined,
"wrapRichTextChunksInFragment": undefined,

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

@@ -25,7 +25,8 @@ import {GlobalState} from 'types/store';
import {getCurrentMomentForTimezone} from 'utils/timezone';
import {A11yCustomEventTypes, A11yFocusEventDetail, Constants, ModalIdentifiers} from 'utils/constants';
import {t} from 'utils/i18n';
import {isKeyPressed, localizeMessage} from 'utils/utils';
import {isKeyPressed} from 'utils/keyboard';
import {localizeMessage} from 'utils/utils';
import CustomStatusSuggestion from 'components/custom_status/custom_status_suggestion';
import ExpiryMenu from 'components/custom_status/expiry_menu';

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

@@ -17,7 +17,8 @@ import DatePicker from 'components/date_picker';
import Menu from 'components/widgets/menu/menu';
import Timestamp from 'components/timestamp';
import {getCurrentLocale} from 'selectors/i18n';
import {isKeyPressed, localizeMessage} from 'utils/utils';
import {isKeyPressed} from 'utils/keyboard';
import {localizeMessage} from 'utils/utils';
import {getCurrentMomentForTimezone} from 'utils/timezone';
import Constants, {A11yCustomEventTypes, A11yFocusEventDetail} from 'utils/constants';

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

@@ -22,7 +22,8 @@ import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import './dnd_custom_time_picker_modal.scss';
import {toUTCUnix} from 'utils/datetime';
import {isKeyPressed, localizeMessage} from 'utils/utils';
import {isKeyPressed} from 'utils/keyboard';
import {localizeMessage} from 'utils/utils';
import Input from 'components/widgets/inputs/input/input';
import DatePicker from 'components/date_picker';

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

@@ -28,6 +28,7 @@ import Permissions from 'mattermost-redux/constants/permissions';
import {Locations, ModalIdentifiers, Constants, TELEMETRY_LABELS} from 'utils/constants';
import DeletePostModal from 'components/delete_post_modal';
import DelayedAction from 'utils/delayed_action';
import * as Keyboard from 'utils/keyboard';
import * as PostUtils from 'utils/post_utils';
import * as Menu from 'components/menu';
import * as Utils from 'utils/utils';
@@ -337,61 +338,61 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
const isShiftKeyPressed = e.shiftKey;
switch (true) {
case Utils.isKeyPressed(e, Constants.KeyCodes.R):
case Keyboard.isKeyPressed(e, Constants.KeyCodes.R):
this.handleCommentClick(e);
this.handleDropdownOpened(false);
break;
// edit post
case Utils.isKeyPressed(e, Constants.KeyCodes.E):
case Keyboard.isKeyPressed(e, Constants.KeyCodes.E):
this.handleEditMenuItemActivated(e);
this.handleDropdownOpened(false);
break;
// follow thread
case Utils.isKeyPressed(e, Constants.KeyCodes.F) && !isShiftKeyPressed:
case Keyboard.isKeyPressed(e, Constants.KeyCodes.F) && !isShiftKeyPressed:
this.handleSetThreadFollow(e);
this.handleDropdownOpened(false);
break;
// forward post
case Utils.isKeyPressed(e, Constants.KeyCodes.F) && isShiftKeyPressed:
case Keyboard.isKeyPressed(e, Constants.KeyCodes.F) && isShiftKeyPressed:
this.handleForwardMenuItemActivated(e);
this.handleDropdownOpened(false);
break;
// copy link
case Utils.isKeyPressed(e, Constants.KeyCodes.K):
case Keyboard.isKeyPressed(e, Constants.KeyCodes.K):
this.copyLink(e);
this.handleDropdownOpened(false);
break;
// copy text
case Utils.isKeyPressed(e, Constants.KeyCodes.C):
case Keyboard.isKeyPressed(e, Constants.KeyCodes.C):
this.copyText(e);
this.handleDropdownOpened(false);
break;
// delete post
case Utils.isKeyPressed(e, Constants.KeyCodes.DELETE):
case Keyboard.isKeyPressed(e, Constants.KeyCodes.DELETE):
this.handleDeleteMenuItemActivated(e);
this.handleDropdownOpened(false);
break;
// pin / unpin
case Utils.isKeyPressed(e, Constants.KeyCodes.P):
case Keyboard.isKeyPressed(e, Constants.KeyCodes.P):
this.handlePinMenuItemActivated(e);
this.handleDropdownOpened(false);
break;
// save / unsave
case Utils.isKeyPressed(e, Constants.KeyCodes.S):
case Keyboard.isKeyPressed(e, Constants.KeyCodes.S):
this.handleFlagMenuItemActivated(e);
this.handleDropdownOpened(false);
break;
// mark as unread
case Utils.isKeyPressed(e, Constants.KeyCodes.U):
case Keyboard.isKeyPressed(e, Constants.KeyCodes.U):
this.handleMarkPostAsUnread(e);
this.handleDropdownOpened(false);
break;
@@ -496,7 +497,7 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
class: classNames('post-menu__item', {
'post-menu__item--active': this.props.isMenuOpen,
}),
'aria-label': formatMessage({id: 'post_info.dot_menu.tooltip.more_actions', defaultMessage: 'Actions'}),
'aria-label': formatMessage({id: 'post_info.dot_menu.tooltip.actions', defaultMessage: 'Actions'}),
children: <DotsHorizontalIcon size={16}/>,
}}
menu={{
@@ -509,7 +510,7 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
}}
menuButtonTooltip={{
id: `PostDotMenu-ButtonTooltip-${this.props.post.id}`,
text: formatMessage({id: 'post_info.dot_menu.tooltip.more_actions', defaultMessage: 'More'}),
text: formatMessage({id: 'post_info.dot_menu.tooltip.more', defaultMessage: 'More'}),
class: 'hidden-xs',
}}
>

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

@@ -39,6 +39,7 @@ exports[`components/drafts/drafts_row should match snapshot for channel draft 1`
"type": "channel",
}
}
isRemote={false}
status={Object {}}
user={Object {}}
/>
@@ -84,6 +85,7 @@ exports[`components/drafts/drafts_row should match snapshot for thread draft 1`]
"type": "thread",
}
}
isRemote={false}
status={Object {}}
user={Object {}}
/>

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

@@ -34,6 +34,7 @@ exports[`components/drafts/drafts should match snapshot 1`] = `
>
<Memo(Drafts)
displayName="display_name"
draftRemotes={Object {}}
drafts={Array []}
localDraftsAreEnabled={true}
status={Object {}}
@@ -76,6 +77,7 @@ exports[`components/drafts/drafts should match snapshot for local drafts disable
>
<Memo(Drafts)
displayName="display_name"
draftRemotes={Object {}}
drafts={Array []}
localDraftsAreEnabled={false}
status={Object {}}

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

@@ -42,6 +42,7 @@ exports[`components/drafts/drafts_row should match snapshot for channel draft 1`
displayName=""
draftId=""
id={Object {}}
isRemote={false}
status={Object {}}
type="channel"
user={Object {}}
@@ -88,6 +89,7 @@ exports[`components/drafts/drafts_row should match snapshot for undefined channe
displayName=""
draftId=""
id={Object {}}
isRemote={false}
status={Object {}}
type="channel"
user={Object {}}

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

@@ -26,6 +26,7 @@ describe('components/drafts/drafts_row', () => {
type: 'channel' as 'channel' | 'thread',
user: {} as UserProfile,
value: {} as PostDraft,
isRemote: false,
};
it('should match snapshot for channel draft', () => {

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

@@ -29,6 +29,7 @@ type Props = {
type: 'channel' | 'thread';
user: UserProfile;
value: PostDraft;
isRemote: boolean;
}
function ChannelDraft({
@@ -40,6 +41,7 @@ function ChannelDraft({
type,
user,
value,
isRemote,
}: Props) {
const dispatch = useDispatch();
const history = useHistory();
@@ -101,7 +103,7 @@ function ChannelDraft({
/>
)}
timestamp={value.updateAt}
remote={value.remote || false}
remote={isRemote || false}
/>
<PanelBody
channelId={channel.id}

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

@@ -6,7 +6,7 @@ import {connect} from 'react-redux';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getChannelURL} from 'utils/utils';
import {getChannelURL} from 'selectors/urls';
import {GlobalState} from 'types/store';

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

@@ -21,6 +21,7 @@ describe('components/drafts/drafts_row', () => {
user: {} as UserProfile,
status: {} as UserStatus['status'],
displayName: 'test',
isRemote: false,
};
it('should match snapshot for channel draft', () => {

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

@@ -14,9 +14,10 @@ type Props = {
status: UserStatus['status'];
displayName: string;
draft: Draft;
isRemote: boolean;
}
function DraftRow({draft, user, status, displayName}: Props) {
function DraftRow({draft, user, status, displayName, isRemote}: Props) {
switch (draft.type) {
case 'channel':
return (
@@ -26,6 +27,7 @@ function DraftRow({draft, user, status, displayName}: Props) {
user={user}
status={status}
displayName={displayName}
isRemote={isRemote}
/>
);
case 'thread':
@@ -37,6 +39,7 @@ function DraftRow({draft, user, status, displayName}: Props) {
user={user}
status={status}
displayName={displayName}
isRemote={isRemote}
/>
);
default:

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

@@ -20,6 +20,7 @@ describe('components/drafts/drafts', () => {
displayName: 'display_name',
status: {} as UserStatus['status'],
localDraftsAreEnabled: true,
draftRemotes: {},
};
it('should match snapshot', () => {

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

@@ -27,11 +27,13 @@ type Props = {
displayName: string;
status: UserStatus['status'];
localDraftsAreEnabled: boolean;
draftRemotes: Record<string, boolean>;
}
function Drafts({
displayName,
drafts,
draftRemotes,
status,
user,
localDraftsAreEnabled,
@@ -75,6 +77,7 @@ function Drafts({
key={d.key}
displayName={displayName}
draft={d}
isRemote={draftRemotes[d.key]}
user={user}
status={status}
/>

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

@@ -22,6 +22,7 @@ function makeMapStateToProps() {
return {
displayName: displayUsername(user, getTeammateNameDisplaySetting(state)),
drafts: getDrafts(state),
draftRemotes: state.views.drafts.remotes,
status,
user,
localDraftsAreEnabled: localDraftsAreEnabled(state),

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

@@ -73,9 +73,11 @@ exports[`components/drafts/panel/panel_body should have called handleFormattedTe
hide={[Function]}
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
@@ -102,6 +104,7 @@ exports[`components/drafts/panel/panel_body should have called handleFormattedTe
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,
@@ -351,9 +354,11 @@ exports[`components/drafts/panel/panel_body should match snapshot 1`] = `
hide={[Function]}
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
@@ -380,6 +385,7 @@ exports[`components/drafts/panel/panel_body should match snapshot 1`] = `
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,
@@ -635,9 +641,11 @@ exports[`components/drafts/panel/panel_body should match snapshot for priority 1
hide={[Function]}
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
@@ -664,6 +672,7 @@ exports[`components/drafts/panel/panel_body should match snapshot for priority 1
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,
@@ -777,7 +786,7 @@ exports[`components/drafts/panel/panel_body should match snapshot for priority 1
<Memo(Tag)
icon="alert-circle-outline"
size="xs"
text="IMPORTANT"
text="Important"
uppercase={true}
variant="info"
>
@@ -809,7 +818,7 @@ exports[`components/drafts/panel/panel_body should match snapshot for priority 1
<span
className="TagText-bWgUzx kzWPbz"
>
IMPORTANT
Important
</span>
</TagText>
</div>
@@ -989,9 +998,11 @@ exports[`components/drafts/panel/panel_body should match snapshot for requested_
hide={[Function]}
intl={
Object {
"$t": [Function],
"defaultFormats": Object {},
"defaultLocale": "en",
"defaultRichTextElements": undefined,
"fallbackOnEmptyString": true,
"formatDate": [Function],
"formatDateTimeRange": [Function],
"formatDateToParts": [Function],
@@ -1018,6 +1029,7 @@ exports[`components/drafts/panel/panel_body should match snapshot for requested_
"locale": "en",
"messages": Object {},
"onError": [Function],
"onWarn": [Function],
"textComponent": "span",
"timeZone": "Etc/UTC",
"wrapRichTextChunksInFragment": undefined,

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

@@ -42,6 +42,7 @@ exports[`components/drafts/drafts_row should match snapshot for channel draft 1`
displayName=""
draftId=""
id={Object {}}
isRemote={false}
rootId=""
status={Object {}}
thread={
@@ -98,6 +99,7 @@ exports[`components/drafts/drafts_row should match snapshot for undefined thread
displayName=""
draftId=""
id={Object {}}
isRemote={false}
rootId=""
status={Object {}}
thread={null}

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

@@ -31,6 +31,7 @@ describe('components/drafts/drafts_row', () => {
type: 'thread' as 'channel' | 'thread',
user: {} as UserProfile,
value: {} as PostDraft,
isRemote: false,
};
it('should match snapshot for channel draft', () => {

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

@@ -33,6 +33,7 @@ type Props = {
type: 'channel' | 'thread';
user: UserProfile;
value: PostDraft;
isRemote: boolean;
}
function ThreadDraft({
@@ -45,6 +46,7 @@ function ThreadDraft({
type,
user,
value,
isRemote,
}: Props) {
const dispatch = useDispatch();
@@ -107,7 +109,7 @@ function ThreadDraft({
/>
)}
timestamp={value.updateAt}
remote={value.remote || false}
remote={isRemote || false}
/>
<PanelBody
channelId={channel.id}

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

@@ -13,8 +13,9 @@ import Textbox, {TextboxElement} from 'components/textbox';
import TextboxClass from 'components/textbox/textbox';
import TextboxLinks from 'components/textbox/textbox_links';
import Constants from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {isMobile} from 'utils/user_agent';
import {insertLineBreakFromKeyEvent, isKeyPressed, isUnhandledLineBreakKeyCombo, localizeMessage} from 'utils/utils';
import {insertLineBreakFromKeyEvent, isUnhandledLineBreakKeyCombo, localizeMessage} from 'utils/utils';
const KeyCodes = Constants.KeyCodes;

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

@@ -9,6 +9,7 @@ import {Channel} from '@mattermost/types/channels';
import {ActionResult} from 'mattermost-redux/types/actions';
import Constants from 'utils/constants';
import * as Keyboard from 'utils/keyboard';
import * as Utils from 'utils/utils';
type Actions = {
@@ -68,10 +69,10 @@ export class EditChannelPurposeModal extends React.PureComponent<Props, State> {
if (Utils.isUnhandledLineBreakKeyCombo(e)) {
e.preventDefault();
this.setState({purpose: Utils.insertLineBreakFromKeyEvent(e as React.KeyboardEvent<HTMLTextAreaElement>)});
} else if (ctrlSend && Utils.isKeyPressed(e, Constants.KeyCodes.ENTER) && e.ctrlKey) {
} else if (ctrlSend && Keyboard.isKeyPressed(e, Constants.KeyCodes.ENTER) && e.ctrlKey) {
e.preventDefault();
this.handleSave();
} else if (!ctrlSend && Utils.isKeyPressed(e, Constants.KeyCodes.ENTER) && !e.shiftKey && !e.altKey) {
} else if (!ctrlSend && Keyboard.isKeyPressed(e, Constants.KeyCodes.ENTER) && !e.shiftKey && !e.altKey) {
e.preventDefault();
this.handleSave();
}

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

@@ -10,6 +10,7 @@ import {Post} from '@mattermost/types/posts';
import {Emoji, SystemEmoji} from '@mattermost/types/emojis';
import {AppEvents, Constants, ModalIdentifiers, StoragePrefixes} from 'utils/constants';
import * as Keyboard from 'utils/keyboard';
import {
formatGithubCodePaste,
formatMarkdownMessage,
@@ -220,7 +221,13 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
actions.unsetEditingPost();
};
const handleAutomatedRefocusAndExit = () => handleRefocusAndExit(editingPost.refocusId || null);
const handleAutomatedRefocusAndExit = () => {
draftRef.current = {
...draftRef.current,
message: '',
};
handleRefocusAndExit(editingPost.refocusId || null);
};
const handleEdit = async () => {
if (!editingPost.post || isSaveDisabled()) {
@@ -302,13 +309,13 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
const {ctrlSend, codeBlockOnCtrlEnter} = rest;
const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey;
const ctrlKeyCombo = Utils.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey;
const ctrlAltCombo = Utils.cmdOrCtrlPressed(e, true) && e.altKey;
const ctrlKeyCombo = Keyboard.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey;
const ctrlAltCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.altKey;
const ctrlEnterKeyCombo =
(ctrlSend || codeBlockOnCtrlEnter) &&
Utils.isKeyPressed(e, KeyCodes.ENTER) &&
Keyboard.isKeyPressed(e, KeyCodes.ENTER) &&
ctrlOrMetaKeyPressed;
const markdownLinkKey = Utils.isKeyPressed(e, KeyCodes.K);
const markdownLinkKey = Keyboard.isKeyPressed(e, KeyCodes.K);
// listen for line break key combo and insert new line character
if (Utils.isUnhandledLineBreakKeyCombo(e)) {
@@ -316,7 +323,7 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
setEditText(Utils.insertLineBreakFromKeyEvent(e as React.KeyboardEvent<HTMLTextAreaElement>));
} else if (ctrlEnterKeyCombo) {
handleEdit();
} else if (Utils.isKeyPressed(e, KeyCodes.ESCAPE) && !showEmojiPicker) {
} else if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE) && !showEmojiPicker) {
handleAutomatedRefocusAndExit();
} else if (ctrlAltCombo && markdownLinkKey) {
applyHotkeyMarkdown({
@@ -325,14 +332,14 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
selectionEnd: e.currentTarget.selectionEnd,
message: e.currentTarget.value,
});
} else if (ctrlKeyCombo && Utils.isKeyPressed(e, KeyCodes.B)) {
} else if (ctrlKeyCombo && Keyboard.isKeyPressed(e, KeyCodes.B)) {
applyHotkeyMarkdown({
markdownMode: 'bold',
selectionStart: e.currentTarget.selectionStart,
selectionEnd: e.currentTarget.selectionEnd,
message: e.currentTarget.value,
});
} else if (ctrlKeyCombo && Utils.isKeyPressed(e, KeyCodes.I)) {
} else if (ctrlKeyCombo && Keyboard.isKeyPressed(e, KeyCodes.I)) {
applyHotkeyMarkdown({
markdownMode: 'italic',
selectionStart: e.currentTarget.selectionStart,

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

@@ -8,7 +8,7 @@ import {FormattedMessage} from 'react-intl';
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
import {Preferences} from 'mattermost-redux/constants';
import {isMac} from 'utils/utils';
import {isMac} from 'utils/user_agent';
import {GlobalState} from 'types/store';
type Props = {

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

@@ -18,7 +18,7 @@ function EmojiPickerPreview({emoji}: Props) {
return (
<div className='emoji-picker__preview emoji-picker__preview-placeholder'>
<FormattedMessage
id='emoji_picker.emojiPicker'
id='emoji_picker.emojiPicker.previewPlaceholder'
defaultMessage='Select an Emoji'
/>
</div>

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