* Add report a problem type and allow logs config

* Improve device type logic

* Add tests and minor fixes

* Add texts

* Fix tests by avoiding circular dependencies

* Fix test

* Fix useexternallink updating mailtos, and changing the content of query parameters

* Fix texts

* Fix e2e test

* Fix tsc

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Daniel Espino García
2025-04-25 11:08:39 +02:00
коммит произвёл GitHub
родитель 29f7c895b8
Коммит 71334c6d8b
31 изменённых файлов: 866 добавлений и 539 удалений

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

@@ -1573,6 +1573,12 @@ components:
type: string
ReportAProblemLink:
type: string
ReportAProblemType:
type: string
ReportAProblemMail:
type: string
AllowDownloadLogs:
type: boolean
SupportEmail:
type: string
GitLabSettings:
@@ -2047,6 +2053,12 @@ components:
type: boolean
ReportAProblemLink:
type: boolean
ReportAProblemType:
type: boolean
ReportAProblemMail:
type: boolean
AllowDownloadLogs:
type: boolean
SupportEmail:
type: boolean
GitLabSettings:

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

@@ -42,6 +42,7 @@ describe('SupportSettings', () => {
cy.findByTestId('SupportSettings.PrivacyPolicyLinkinput').clear().type(privacyLink);
cy.findByTestId('SupportSettings.AboutLinkinput').clear().type(aboutLink);
cy.findByTestId('SupportSettings.HelpLinkinput').clear().type(helpLink);
cy.findByTestId('SupportSettings.ReportAProblemTypedropdown').select('Custom link');
cy.findByTestId('SupportSettings.ReportAProblemLinkinput').clear().type(problemLink);
// # Save setting then back to team view
@@ -114,7 +115,7 @@ describe('SupportSettings', () => {
it('MM-T1036 - Customization: Blank Help and Report a Problem hides options from help menu', () => {
// # Change help and report links to blanks
cy.findByTestId('SupportSettings.HelpLinkinput').type('any').clear();
cy.findByTestId('SupportSettings.ReportAProblemLinkinput').type('any').clear();
cy.findByTestId('SupportSettings.ReportAProblemTypedropdown').select('Hide link');
// # Save setting and back to team view
saveSetting();
@@ -157,11 +158,10 @@ describe('SupportSettings', () => {
// * Verify that hover shows "Help" text
cy.uiGetHelpButton().
trigger('mouseover', {force: true}).
should('have.attr', 'aria-describedby').
and('equal', 'userGuideHelpTooltip');
trigger('mouseenter').
should('have.attr', 'aria-describedby');
cy.uiGetHelpButton().
trigger('mouseout', {force: true}).
trigger('mouseleave').
should('not.have.attr', 'aria-describedby');
// # Open help menu
@@ -184,6 +184,7 @@ describe('SupportSettings', () => {
// Edit help link and report a problem link
cy.findByTestId('SupportSettings.HelpLinkinput').clear().type(helpLink);
cy.findByTestId('SupportSettings.ReportAProblemTypedropdown').select('Custom link');
cy.findByTestId('SupportSettings.ReportAProblemLinkinput').clear().type(problemLink);
// # Save setting and back to team view

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

@@ -190,6 +190,7 @@
"AboutLink": "https://mattermost.com/pl/about-mattermost",
"HelpLink": "https://mattermost.com/pl/help/",
"ReportAProblemLink": "https://mattermost.com/pl/report-a-bug",
"ReportAProblemType": "link",
"ForgotPasswordLink": "",
"SupportEmail": "",
"CustomTermsOfServiceEnabled": false,

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

@@ -298,6 +298,7 @@
"AboutLink": "https://mattermost.com/pl/about-mattermost",
"HelpLink": "https://mattermost.com/pl/help/",
"ReportAProblemLink": "https://mattermost.com/pl/report-a-bug",
"ReportAProblemType": "link",
"ForgotPasswordLink": "",
"SupportEmail": "",
"CustomTermsOfServiceEnabled": false,

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

@@ -396,6 +396,9 @@ const defaultServerConfig: AdminConfig = {
AboutLink: 'https://mattermost.com/pl/about-mattermost',
HelpLink: 'https://mattermost.com/pl/help/',
ReportAProblemLink: 'https://mattermost.com/pl/report-a-bug',
ReportAProblemType: 'link',
ReportAProblemMail: '',
AllowDownloadLogs: true,
ForgotPasswordLink: '',
SupportEmail: '',
CustomTermsOfServiceEnabled: false,

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

@@ -294,7 +294,10 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m
props["PrivacyPolicyLink"] = *c.SupportSettings.PrivacyPolicyLink
props["AboutLink"] = *c.SupportSettings.AboutLink
props["HelpLink"] = *c.SupportSettings.HelpLink
props["ReportAProblemType"] = *c.SupportSettings.ReportAProblemType
props["ReportAProblemLink"] = *c.SupportSettings.ReportAProblemLink
props["ReportAProblemMail"] = *c.SupportSettings.ReportAProblemMail
props["AllowDownloadLogs"] = strconv.FormatBool(*c.SupportSettings.AllowDownloadLogs)
props["ForgotPasswordLink"] = *c.SupportSettings.ForgotPasswordLink
props["SupportEmail"] = *c.SupportSettings.SupportEmail
props["EnableAskCommunityLink"] = strconv.FormatBool(*c.SupportSettings.EnableAskCommunityLink)

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

@@ -319,6 +319,25 @@ func TestGetClientConfig(t *testing.T) {
"GiphySdkKey": model.ServiceSettingsDefaultGiphySdkKeyTest,
},
},
{
"report a problem values",
&model.Config{
SupportSettings: model.SupportSettings{
ReportAProblemType: model.NewPointer("type"),
ReportAProblemLink: model.NewPointer("http://example.com"),
ReportAProblemMail: model.NewPointer("mail"),
AllowDownloadLogs: model.NewPointer(true),
},
},
"",
nil,
map[string]string{
"ReportAProblemType": "type",
"ReportAProblemLink": "http://example.com",
"ReportAProblemMail": "mail",
"AllowDownloadLogs": "true",
},
},
}
for _, testCase := range testCases {

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

@@ -9308,6 +9308,22 @@
"id": "model.config.is_valid.read_timeout.app_error",
"translation": "Invalid value for read timeout."
},
{
"id": "model.config.is_valid.report_a_problem_link.invalid.app_error",
"translation": "Invalid report a problem link. Must be a valid URL and start with http:// or https://."
},
{
"id": "model.config.is_valid.report_a_problem_link.missing.app_error",
"translation": "Report a problem link is required."
},
{
"id": "model.config.is_valid.report_a_problem_mail.invalid.app_error",
"translation": "Invalid report a problem mail. Must be a valid email address."
},
{
"id": "model.config.is_valid.report_a_problem_mail.missing.app_error",
"translation": "Report a problem mail is required."
},
{
"id": "model.config.is_valid.restrict_direct_message.app_error",
"translation": "Invalid direct message restriction. Must be 'any', or 'team'."

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

@@ -746,6 +746,8 @@ func (ts *TelemetryService) trackConfig() {
"custom_terms_of_service_enabled": *cfg.SupportSettings.CustomTermsOfServiceEnabled,
"custom_terms_of_service_re_acceptance_period": *cfg.SupportSettings.CustomTermsOfServiceReAcceptancePeriod,
"enable_ask_community_link": *cfg.SupportSettings.EnableAskCommunityLink,
"report_a_problem_type": *cfg.SupportSettings.ReportAProblemType,
"allow_download_logs": *cfg.SupportSettings.AllowDownloadLogs,
}
configs[TrackConfigLDAP] = map[string]any{

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

@@ -147,6 +147,12 @@ const (
SupportSettingsDefaultSupportEmail = ""
SupportSettingsDefaultReAcceptancePeriod = 365
SupportSettingsReportAProblemTypeLink = "link"
SupportSettingsReportAProblemTypeMail = "email"
SupportSettingsReportAProblemTypeHidden = "hidden"
SupportSettingsReportAProblemTypeDefault = "default"
SupportSettingsDefaultReportAProblemType = SupportSettingsReportAProblemTypeDefault
LdapSettingsDefaultFirstNameAttribute = ""
LdapSettingsDefaultLastNameAttribute = ""
LdapSettingsDefaultEmailAttribute = ""
@@ -2146,6 +2152,9 @@ type SupportSettings struct {
AboutLink *string `access:"site_customization,write_restrictable,cloud_restrictable"`
HelpLink *string `access:"site_customization"`
ReportAProblemLink *string `access:"site_customization,write_restrictable,cloud_restrictable"`
ReportAProblemType *string `access:"site_customization,write_restrictable,cloud_restrictable"`
ReportAProblemMail *string `access:"site_customization,write_restrictable,cloud_restrictable"`
AllowDownloadLogs *bool `access:"site_customization,write_restrictable,cloud_restrictable"`
ForgotPasswordLink *string `access:"site_customization,write_restrictable,cloud_restrictable"`
SupportEmail *string `access:"site_notifications"`
CustomTermsOfServiceEnabled *bool `access:"compliance_custom_terms_of_service"`
@@ -2194,6 +2203,18 @@ func (s *SupportSettings) SetDefaults() {
s.ReportAProblemLink = NewPointer(SupportSettingsDefaultReportAProblemLink)
}
if s.ReportAProblemType == nil {
s.ReportAProblemType = NewPointer(SupportSettingsDefaultReportAProblemType)
}
if s.ReportAProblemMail == nil {
s.ReportAProblemMail = NewPointer("")
}
if s.AllowDownloadLogs == nil {
s.AllowDownloadLogs = NewPointer(true)
}
if !isSafeLink(s.ForgotPasswordLink) {
*s.ForgotPasswordLink = ""
}
@@ -3987,6 +4008,26 @@ func (o *Config) IsValid() *AppError {
return appErr
}
if o.SupportSettings.ReportAProblemType != nil {
if *o.SupportSettings.ReportAProblemType == SupportSettingsReportAProblemTypeMail {
if o.SupportSettings.ReportAProblemMail == nil {
return NewAppError("Config.IsValid", "model.config.is_valid.report_a_problem_mail.missing.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidEmail(*o.SupportSettings.ReportAProblemMail) {
return NewAppError("Config.IsValid", "model.config.is_valid.report_a_problem_mail.invalid.app_error", nil, "", http.StatusBadRequest)
}
}
if *o.SupportSettings.ReportAProblemType == SupportSettingsReportAProblemTypeLink {
if o.SupportSettings.ReportAProblemLink == nil {
return NewAppError("Config.IsValid", "model.config.is_valid.report_a_problem_link.missing.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidHTTPURL(*o.SupportSettings.ReportAProblemLink) {
return NewAppError("Config.IsValid", "model.config.is_valid.report_a_problem_link.invalid.app_error", nil, "", http.StatusBadRequest)
}
}
}
return nil
}

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

@@ -61,6 +61,52 @@ func TestConfigDefaults(t *testing.T) {
c.SetDefaults()
recursivelyUninitialize(&c, "config", reflect.ValueOf(&c).Elem())
})
t.Run("report a problem defaults", func(t *testing.T) {
c := Config{}
c.SetDefaults()
require.Equal(t, SupportSettingsDefaultReportAProblemType, *c.SupportSettings.ReportAProblemType)
require.Equal(t, SupportSettingsDefaultReportAProblemLink, *c.SupportSettings.ReportAProblemLink)
require.Equal(t, "", *c.SupportSettings.ReportAProblemMail)
require.Equal(t, true, *c.SupportSettings.AllowDownloadLogs)
})
}
func TestConfigIsValid(t *testing.T) {
t.Run("report a problem values", func(t *testing.T) {
t.Run("email", func(t *testing.T) {
c := Config{}
c.SetDefaults()
c.SupportSettings.ReportAProblemType = NewPointer(string(SupportSettingsReportAProblemTypeMail))
c.SupportSettings.ReportAProblemMail = nil
require.NotNil(t, c.IsValid())
c.SupportSettings.ReportAProblemMail = NewPointer("")
require.NotNil(t, c.IsValid())
c.SupportSettings.ReportAProblemMail = NewPointer("invalid")
require.NotNil(t, c.IsValid())
c.SupportSettings.ReportAProblemMail = NewPointer("valid@email.com")
require.Nil(t, c.IsValid())
})
t.Run("link", func(t *testing.T) {
c := Config{}
c.SetDefaults()
c.SupportSettings.ReportAProblemType = NewPointer(string(SupportSettingsReportAProblemTypeLink))
c.SupportSettings.ReportAProblemLink = nil
require.NotNil(t, c.IsValid())
c.SupportSettings.ReportAProblemLink = NewPointer("")
require.NotNil(t, c.IsValid())
c.SupportSettings.ReportAProblemLink = NewPointer("invalid")
require.NotNil(t, c.IsValid())
c.SupportSettings.ReportAProblemLink = NewPointer("http://valid.com")
require.Nil(t, c.IsValid())
})
})
}
func TestConfigEmptySiteName(t *testing.T) {

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

@@ -189,6 +189,7 @@
"AboutLink": "https://mattermost.com/default-about/",
"HelpLink": "https://mattermost.com/pl/help/",
"ReportAProblemLink": "https://mattermost.com/pl/report-a-bug",
"ReportAProblemType": "link",
"ForgotPasswordLink": "",
"SupportEmail": "feedback@mattermost.com"
},

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

@@ -2206,13 +2206,97 @@ const AdminDefinition: AdminDefinitionType = {
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.CUSTOMIZATION)),
isHidden: it.configIsTrue('ExperimentalSettings', 'RestrictSystemAdmin'),
},
{
type: 'dropdown',
key: 'SupportSettings.ReportAProblemType',
label: defineMessage({id: 'admin.support.reportAProblemTypeTitle', defaultMessage: 'Report a Problem:'}),
help_text: defineMessage({id: 'admin.support.reportAProblemTypeDescription', defaultMessage: 'Select how the Report a Problem option behaves. Choosing Custom link or Email address allows you to provide a URL or address in the next field. Hide link removes the Report a Problem option from the app.'}),
options: [
{
display_name: defineMessage({id: 'admin.support.problemType.defaultLink', defaultMessage: 'Default link'}),
value: 'default',
},
{
display_name: defineMessage({id: 'admin.support.problemType.email', defaultMessage: 'Email address'}),
value: 'email',
},
{
display_name: defineMessage({id: 'admin.support.problemType.customLink', defaultMessage: 'Custom link'}),
value: 'link',
},
{
display_name: defineMessage({id: 'admin.support.problemType.hide', defaultMessage: 'Hide link'}),
value: 'hidden',
},
],
},
{
type: 'text',
key: 'defaultLicensedReportAProblemLink',
label: defineMessage({id: 'admin.support.reportAProblemDefaultLinkTitle', defaultMessage: 'Default Report a Problem Link:'}),
help_text: defineMessage({id: 'admin.support.reportAProblemDefaultLinkDescription', defaultMessage: 'Users will be directed to this link when they choose Report a Problem.'}),
default: 'https://mattermost.com/pl/report_a_problem_licensed',
isDisabled: it.all(),
isHidden: it.any(
it.configIsTrue('ExperimentalSettings', 'RestrictSystemAdmin'),
it.not(it.stateMatches('SupportSettings.ReportAProblemType', /default/)),
it.not(it.licensed),
),
},
{
type: 'text',
key: 'defaultUnlicensedReportAProblemLink',
label: defineMessage({id: 'admin.support.reportAProblemDefaultLinkTitle', defaultMessage: 'Default Report a Problem Link:'}),
help_text: defineMessage({id: 'admin.support.reportAProblemDefaultLinkDescription', defaultMessage: 'Users will be directed to this link when they choose Report a Problem.'}),
default: 'https://mattermost.com/pl/report_a_problem_unlicensed',
isDisabled: it.all(),
isHidden: it.any(
it.configIsTrue('ExperimentalSettings', 'RestrictSystemAdmin'),
it.not(it.stateMatches('SupportSettings.ReportAProblemType', /default/)),
it.licensed,
),
},
{
type: 'text',
key: 'SupportSettings.ReportAProblemLink',
label: defineMessage({id: 'admin.support.problemTitle', defaultMessage: 'Report a Problem Link:'}),
help_text: defineMessage({id: 'admin.support.problemDesc', defaultMessage: 'The URL for the Report a Problem link in the Help Menu. If this field is empty, the link is removed from the Help Menu.'}),
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.CUSTOMIZATION)),
isHidden: it.configIsTrue('ExperimentalSettings', 'RestrictSystemAdmin'),
label: defineMessage({id: 'admin.support.reportAProblemLinkTitle', defaultMessage: 'Custom Report a Problem Link:'}),
help_text: defineMessage({id: 'admin.support.reportAProblemLinkDescription', defaultMessage: 'Enter the URL that users will be directed to when they choose Report a Problem.'}),
isDisabled: it.any(
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.CUSTOMIZATION)),
),
isHidden: it.any(
it.configIsTrue('ExperimentalSettings', 'RestrictSystemAdmin'),
it.not(it.stateMatches('SupportSettings.ReportAProblemType', /link/)),
),
validate: (value) => {
if (!value) {
return new ValidationResult(false, defineMessage({id: 'admin.support.reportAProblemLinkError', defaultMessage: 'Link is required'}));
}
return new ValidationResult(true, '');
},
},
{
type: 'text',
key: 'SupportSettings.ReportAProblemMail',
label: defineMessage({id: 'admin.support.reportAProblemEmailTitle', defaultMessage: 'Report a Problem Email Address:'}),
help_text: defineMessage({id: 'admin.support.reportAProblemEmailDescription', defaultMessage: 'Enter the email address that users will be prompted to send a message to when they choose Report a Problem.'}),
isDisabled: (it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.CUSTOMIZATION))),
isHidden: it.any(
it.configIsTrue('ExperimentalSettings', 'RestrictSystemAdmin'),
it.not(it.stateMatches('SupportSettings.ReportAProblemType', /email/)),
),
validate: (value) => {
if (!value) {
return new ValidationResult(false, defineMessage({id: 'admin.support.reportAProblemEmailError', defaultMessage: 'Email is required'}));
}
return new ValidationResult(true, '');
},
},
{
type: 'bool',
key: 'SupportSettings.AllowDownloadLogs',
label: defineMessage({id: 'admin.support.problemAllowDownloadTitle', defaultMessage: 'Allow Mobile App Log Downloads:'}),
help_text: defineMessage({id: 'admin.support.problemAllowDownloadDescription', defaultMessage: 'When enabled, users can download app logs for troubleshooting. If a Report a Problem link is shown, logs can be downloaded as part of that flow; if the Report a Problem link is hidden, logs remain accessible as a separate option.'}),
},
{
type: 'text',

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

@@ -38,6 +38,13 @@ describe('useExternalLink', () => {
expect(queryParams).toEqual({});
});
it('mailto links are untouched even if to mattermost emails', () => {
const mailtoURL = 'mailto:example@mattermost.com?subject=123&body=456';
const {result: {current: [mailtoHref, mailtoQueryParams]}} = renderHookWithContext(() => useExternalLink(mailtoURL), getBaseState());
expect(mailtoHref).toEqual(mailtoURL);
expect(mailtoQueryParams).toEqual({});
});
it('all base queries are set correctly', () => {
const url = 'https://www.mattermost.com/some/url';
const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url), getBaseState());
@@ -129,4 +136,9 @@ describe('useExternalLink', () => {
expect(firstHref).toBe(secondHref);
expect(firstParams).toBe(secondParams);
});
it('do not substitute %20 on query params', () => {
const url = 'https://www.mattermost.com/some/url?subject=hello%20world';
const {result: {current: [href]}} = renderHookWithContext(() => useExternalLink(url), getBaseState());
expect(href).toContain('subject=hello%20world');
});
});

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

@@ -23,7 +23,7 @@ export function useExternalLink(href: string, location: string = '', overwriteQu
const isCloud = useSelector((state: GlobalState) => getLicense(state).Cloud === 'true');
return useMemo(() => {
if (!href?.includes('mattermost.com')) {
if (!href?.includes('mattermost.com') || href?.startsWith('mailto:')) {
return [href, {}];
}
@@ -40,7 +40,7 @@ export function useExternalLink(href: string, location: string = '', overwriteQu
...overwriteQueryParams,
...existingQueryParamsObj,
};
parsedUrl.search = new URLSearchParams(queryParams).toString();
parsedUrl.search = Object.entries(queryParams).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');
return [parsedUrl.toString(), queryParams];
}, [href, isCloud, location, overwriteQueryParams, telemetryId, userId]);

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

@@ -8,30 +8,26 @@ import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getIsOnboardingFlowEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams';
import {getReportAProblemLink} from 'mattermost-redux/selectors/entities/report_a_problem';
import {isFirstAdmin} from 'mattermost-redux/selectors/entities/users';
import {openModal} from 'actions/views/modals';
import {getUserGuideDropdownPluginMenuItems} from 'selectors/plugins';
import {getIsMobileView} from 'selectors/views/browser';
import type {GlobalState} from 'types/store';
import UserGuideDropdown from './user_guide_dropdown';
function mapStateToProps(state: GlobalState) {
const {HelpLink, ReportAProblemLink, EnableAskCommunityLink} = getConfig(state);
const {HelpLink, EnableAskCommunityLink} = getConfig(state);
const reportAProblemLink = getReportAProblemLink(state);
return {
helpLink: HelpLink || '',
isMobileView: getIsMobileView(state),
reportAProblemLink: ReportAProblemLink || '',
reportAProblemLink,
enableAskCommunityLink: EnableAskCommunityLink || '',
teamUrl: getCurrentRelativeTeamUrl(state),
pluginMenuItems: getUserGuideDropdownPluginMenuItems(state),
isFirstAdmin: isFirstAdmin(state),
onboardingFlowEnabled: getIsOnboardingFlowEnabled(state),
};
}

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

@@ -11,6 +11,7 @@ import {
getConfig,
getLicense,
} from 'mattermost-redux/selectors/entities/general';
import {getReportAProblemLink} from 'mattermost-redux/selectors/entities/report_a_problem';
import {
getJoinableTeamIds,
getCurrentTeam,
@@ -34,7 +35,7 @@ function mapStateToProps(state: GlobalState) {
const siteName = config.SiteName;
const experimentalPrimaryTeam = config.ExperimentalPrimaryTeam;
const helpLink = config.HelpLink;
const reportAProblemLink = config.ReportAProblemLink;
const reportAProblemLink = getReportAProblemLink(state);
const joinableTeams = getJoinableTeamIds(state);
const moreTeamsToJoin = joinableTeams && joinableTeams.length > 0;

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

@@ -18,7 +18,7 @@ describe('MobileSidebarRightItems', () => {
appDownloadLink: undefined,
experimentalPrimaryTeam: undefined,
helpLink: undefined,
reportAProblemLink: undefined,
reportAProblemLink: '',
moreTeamsToJoin: false,
pluginMenuItems: [],
isMentionSearch: false,

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

@@ -20,8 +20,6 @@ import {
updateSearchTermsForShortcut,
showSearchResults,
showChannelFiles,
showMentions,
showFlaggedPosts,
closeRightHandSide,
updateRhsState,
setRhsExpanded,
@@ -48,7 +46,6 @@ function mapStateToProps(state: GlobalState) {
return {
currentChannel,
isRhsExpanded: getIsRhsExpanded(state),
isRhsOpen,
isSearchingTerm: getIsSearchingTerm(state),
searchTerms: getSearchTerms(state),
searchTeam: getSearchTeam(state),
@@ -82,8 +79,6 @@ function mapDispatchToProps(dispatch: Dispatch) {
updateSearchType,
showSearchResults,
showChannelFiles,
showMentions,
showFlaggedPosts,
setRhsExpanded,
closeRightHandSide,
autocompleteChannelsForSearch: autocompleteChannels,

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

@@ -1,16 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useEffect, useState, useRef, useCallback} from 'react';
import type {ChangeEvent, MouseEvent, FormEvent} from 'react';
import type {ChangeEvent, FormEvent} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {getCurrentChannelNameForSearchShortcut} from 'mattermost-redux/selectors/entities/channels';
import HeaderIconWrapper from 'components/channel_header/components/header_icon_wrapper';
import UserGuideDropdown from 'components/search/user_guide_dropdown';
import SearchBar from 'components/search_bar/search_bar';
import SearchHint from 'components/search_hint/search_hint';
import SearchResults from 'components/search_results';
@@ -18,11 +16,8 @@ import type Provider from 'components/suggestion/provider';
import SearchChannelProvider from 'components/suggestion/search_channel_provider';
import SearchDateProvider from 'components/suggestion/search_date_provider';
import SearchUserProvider from 'components/suggestion/search_user_provider';
import FlagIcon from 'components/widgets/icons/flag_icon';
import MentionsIcon from 'components/widgets/icons/mentions_icon';
import SearchIcon from 'components/widgets/icons/search_icon';
import Popover from 'components/widgets/popover';
import {ShortcutKeys} from 'components/with_tooltip/tooltip_shortcut';
import Constants, {searchHintOptions, RHSStates, searchFilesHintOptions} from 'utils/constants';
import * as Keyboard from 'utils/keyboard';
@@ -33,11 +28,6 @@ import type {SearchType} from 'types/store/rhs';
import type {Props, SearchFilterType} from './types';
const mentionsShortcut = {
default: [ShortcutKeys.ctrl, ShortcutKeys.shift, 'M'],
mac: [ShortcutKeys.cmd, ShortcutKeys.shift, 'M'],
};
interface SearchHintOption {
searchTerm: string;
message: {
@@ -87,19 +77,46 @@ const determineVisibleSearchHintOptions = (searchTerms: string, searchType: Sear
return newVisibleSearchHintOptions;
};
const Search: React.FC<Props> = (props: Props): JSX.Element => {
const {
actions,
currentChannel,
enableFindShortcut,
hideSearchBar,
isMobileView,
searchTerms,
searchType,
searchTeam,
hideMobileSearchBarInRHS,
} = props;
const Search = ({
actions: {
autocompleteChannelsForSearch,
autocompleteUsersInTeam,
closeRightHandSide,
filterFilesSearchByExt,
getMoreFilesForSearch,
getMorePostsForSearch,
openRHSSearch,
setRhsExpanded,
showChannelFiles,
showSearchResults,
updateRhsState,
updateSearchTeam,
updateSearchTerms,
updateSearchTermsForShortcut,
updateSearchType,
},
crossTeamSearchEnabled,
hideMobileSearchBarInRHS,
isChannelFiles,
isFlaggedPosts,
isMentionSearch,
isMobileView,
isPinnedPosts,
isRhsExpanded,
isSearchingTerm,
searchTeam,
searchTerms,
searchType,
searchVisible,
channelDisplayName,
children,
currentChannel,
enableFindShortcut,
getFocus,
hideSearchBar,
isSideBarRight,
isSideBarRightOpen,
}: Props): JSX.Element => {
const intl = useIntl();
const currentChannelName = useSelector(getCurrentChannelNameForSearchShortcut);
@@ -116,8 +133,8 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
const suggestionProviders = useRef<Provider[]>([
new SearchDateProvider(),
new SearchChannelProvider(actions.autocompleteChannelsForSearch),
new SearchUserProvider(actions.autocompleteUsersInTeam),
new SearchChannelProvider(autocompleteChannelsForSearch),
new SearchUserProvider(autocompleteUsersInTeam),
]);
const isDesktop = isDesktopApp() && isServerVersionGreaterThanOrEqualTo(getDesktopVersion(), '4.7.0');
@@ -139,11 +156,11 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
e.preventDefault();
if (hideSearchBar) {
actions.openRHSSearch();
openRHSSearch();
setKeepInputFocused(true);
}
if (currentChannelName) {
actions.updateSearchTermsForShortcut();
updateSearchTermsForShortcut();
}
handleFocus();
}
@@ -156,10 +173,10 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
}, [hideSearchBar, currentChannelName]);
useEffect((): void => {
if (isMobileView && props.isSideBarRight) {
if (isMobileView && isSideBarRight) {
handleFocus();
}
}, [isMobileView, props.isSideBarRight]);
}, [isMobileView, isSideBarRight]);
useEffect((): void => {
if (!isMobileView) {
@@ -173,24 +190,24 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
}
}, [isMobileView, searchTerms]);
const getMorePostsForSearch = useCallback(() => {
const getMorePostsForSearchCallback = useCallback(() => {
let team = searchTeam;
if (props.isMentionSearch) {
if (isMentionSearch) {
team = '';
}
props.actions.getMorePostsForSearch(team);
}, [searchTeam, props.actions, props.isMentionSearch]);
getMorePostsForSearch(team);
}, [searchTeam, isMentionSearch, getMorePostsForSearch]);
const getMoreFilesForSearch = useCallback(() => {
const getMoreFilesForSearchCallback = useCallback(() => {
let team = searchTeam;
if (props.isMentionSearch) {
if (isMentionSearch) {
team = '';
}
props.actions.getMoreFilesForSearch(team);
}, [searchTeam, props.actions]);
getMoreFilesForSearch(team);
}, [searchTeam, isMentionSearch, getMoreFilesForSearch]);
// handle cloding of rhs-flyout
const handleClose = (): void => actions.closeRightHandSide();
const handleClose = (): void => closeRightHandSide();
// focus the search input
const handleFocus = (): void => setFocused(true);
@@ -231,13 +248,13 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
};
const handleUpdateSearchTeamFromResult = async (teamId: string) => {
actions.updateSearchTeam(teamId);
updateSearchTeam(teamId);
const newTerms = searchTerms.
replace(/\bin:[^\s]*/gi, '').replace(/\s{2,}/g, ' ').
replace(/\bfrom:[^\s]*/gi, '').replace(/\s{2,}/g, ' ');
if (newTerms.trim() !== searchTerms.trim()) {
actions.updateSearchTerms(newTerms);
updateSearchTerms(newTerms);
}
handleSearch().then(() => {
@@ -247,12 +264,12 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
};
const handleUpdateSearchTerms = (terms: string): void => {
actions.updateSearchTerms(terms);
updateSearchTerms(terms);
updateHighlightedSearchHint();
};
const handleOnSearchTypeSelected = (searchType || searchTerms) ? undefined : (value: SearchType) => {
actions.updateSearchType(value);
updateSearchType(value);
if (!searchType) {
setDropdownFocused(false);
}
@@ -261,7 +278,7 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
const term = e.target.value;
actions.updateSearchTerms(term);
updateSearchTerms(term);
};
// call this function without parameters to reset `SearchHint`
@@ -299,7 +316,7 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
if (indexChangedViaKeyPress) {
setKeepInputFocused(true);
if (!searchType && !searchTerms) {
actions.updateSearchType(highlightedSearchHintIndex === 0 ? 'messages' : 'files');
updateSearchType(highlightedSearchHintIndex === 0 ? 'messages' : 'files');
setHighlightedSearchHintIndex(-1);
} else {
handleAddSearchTerm(visibleSearchHintOptions[highlightedSearchHintIndex].searchTerm);
@@ -307,8 +324,8 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
return;
}
if (props.isMentionSearch) {
actions.updateRhsState(RHSStates.SEARCH);
if (isMentionSearch) {
updateRhsState(RHSStates.SEARCH);
}
handleSearch().then(() => {
@@ -333,7 +350,7 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
return;
}
const {error} = await actions.showSearchResults(Boolean(props.isMentionSearch)) as any;
const {error} = await showSearchResults(Boolean(isMentionSearch)) as any;
if (!error) {
handleSearchOnSuccess();
@@ -347,50 +364,50 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
};
const handleClear = (): void => {
if (props.isMentionSearch) {
if (isMentionSearch) {
setFocused(false);
actions.updateRhsState(RHSStates.SEARCH);
updateRhsState(RHSStates.SEARCH);
}
actions.updateSearchTerms('');
actions.updateSearchTeam(null);
actions.updateSearchType('');
updateSearchTerms('');
updateSearchTeam(null);
updateSearchType('');
};
const handleShrink = (): void => {
props.actions.setRhsExpanded(false);
setRhsExpanded(false);
};
const handleSetSearchFilter = (filterType: SearchFilterType): void => {
switch (filterType) {
case 'documents':
props.actions.filterFilesSearchByExt(['doc', 'pdf', 'docx', 'odt', 'rtf', 'txt']);
filterFilesSearchByExt(['doc', 'pdf', 'docx', 'odt', 'rtf', 'txt']);
break;
case 'spreadsheets':
props.actions.filterFilesSearchByExt(['xls', 'xlsx', 'ods']);
filterFilesSearchByExt(['xls', 'xlsx', 'ods']);
break;
case 'presentations':
props.actions.filterFilesSearchByExt(['ppt', 'pptx', 'odp']);
filterFilesSearchByExt(['ppt', 'pptx', 'odp']);
break;
case 'code':
props.actions.filterFilesSearchByExt(['py', 'go', 'java', 'kt', 'c', 'cpp', 'h', 'html', 'js', 'ts', 'cs', 'vb', 'php', 'pl', 'r', 'rb', 'sql', 'swift', 'json']);
filterFilesSearchByExt(['py', 'go', 'java', 'kt', 'c', 'cpp', 'h', 'html', 'js', 'ts', 'cs', 'vb', 'php', 'pl', 'r', 'rb', 'sql', 'swift', 'json']);
break;
case 'images':
props.actions.filterFilesSearchByExt(['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'svg', 'psd', 'xcf']);
filterFilesSearchByExt(['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'svg', 'psd', 'xcf']);
break;
case 'audio':
props.actions.filterFilesSearchByExt(['ogg', 'mp3', 'wav', 'flac']);
filterFilesSearchByExt(['ogg', 'mp3', 'wav', 'flac']);
break;
case 'video':
props.actions.filterFilesSearchByExt(['ogm', 'mp4', 'avi', 'webm', 'mov', 'mkv', 'mpeg', 'mpg']);
filterFilesSearchByExt(['ogm', 'mp4', 'avi', 'webm', 'mov', 'mkv', 'mpeg', 'mpg']);
break;
default:
props.actions.filterFilesSearchByExt([]);
filterFilesSearchByExt([]);
}
setSearchFilterType(filterType);
if (props.isChannelFiles && currentChannel) {
props.actions.showChannelFiles(currentChannel.id);
if (isChannelFiles && currentChannel) {
showChannelFiles(currentChannel.id);
} else {
props.actions.showSearchResults(false);
showSearchResults(false);
}
};
@@ -399,64 +416,12 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
setIndexChangedViaKeyPress(false);
};
const searchMentions = (e: MouseEvent<HTMLButtonElement>): void => {
e.preventDefault();
if (props.isMentionSearch) {
actions.closeRightHandSide();
return;
}
actions.showMentions();
};
const getFlagged = (e: MouseEvent<HTMLButtonElement>): void => {
e.preventDefault();
if (props.isFlaggedPosts) {
actions.closeRightHandSide();
return;
}
actions.showFlaggedPosts();
};
const searchButtonClick = (e: React.MouseEvent) => {
e.preventDefault();
actions.openRHSSearch();
openRHSSearch();
};
const renderMentionButton = (): JSX.Element => (
<HeaderIconWrapper
buttonClass={classNames(
'channel-header__icon',
{'channel-header__icon--active': props.isMentionSearch},
)}
buttonId={props.isSideBarRight ? 'sbrChannelHeaderMentionButton' : 'channelHeaderMentionButton'}
onClick={searchMentions}
tooltip={intl.formatMessage({id: 'channel_header.recentMentions', defaultMessage: 'Recent mentions'})}
tooltipShortcut={mentionsShortcut}
isRhsOpen={props.isRhsOpen}
>
<MentionsIcon
className='icon icon--standard'
aria-hidden='true'
/>
</HeaderIconWrapper>
);
const renderFlagBtn = (): JSX.Element => (
<HeaderIconWrapper
buttonClass={classNames(
'channel-header__icon ',
{'channel-header__icon--active': props.isFlaggedPosts},
)}
buttonId={props.isSideBarRight ? 'sbrChannelHeaderFlagButton' : 'channelHeaderFlagButton'}
onClick={getFlagged}
tooltip={intl.formatMessage({id: 'channel_header.flagged', defaultMessage: 'Saved messages'})}
isRhsOpen={props.isRhsOpen}
>
<FlagIcon className='icon icon--standard'/>
</HeaderIconWrapper>
);
const renderHintPopover = (): JSX.Element => {
let termsUsed = 0;
@@ -470,7 +435,7 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
}
});
if (visibleSearchHintOptions.length === 0 || props.isMentionSearch) {
if (visibleSearchHintOptions.length === 0 || isMentionSearch) {
return <></>;
}
@@ -478,7 +443,7 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
return (
<Popover
id={`${props.isSideBarRight ? 'sbr-' : ''}searchbar-help-popup`}
id={`${isSideBarRight ? 'sbr-' : ''}searchbar-help-popup`}
placement='bottom'
className={helpClass}
>
@@ -502,7 +467,7 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
<>
<div className='sidebar-collapse__container'>
<div
id={props.isSideBarRight ? 'sbrSidebarCollapse' : 'sidebarCollapse'}
id={isSideBarRight ? 'sbrSidebarCollapse' : 'sidebarCollapse'}
className='sidebar-collapse'
onClick={handleClose}
>
@@ -524,20 +489,20 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
setKeepFocused={setKeepInputFocused}
isFocused={focused}
suggestionProviders={suggestionProviders.current}
isSideBarRight={props.isSideBarRight}
isSearchingTerm={props.isSearchingTerm}
getFocus={props.getFocus}
isSideBarRight={isSideBarRight}
isSearchingTerm={isSearchingTerm}
getFocus={getFocus}
searchTerms={searchTerms}
searchType={searchType}
clearSearchType={() => actions.updateSearchType('')}
clearSearchType={() => updateSearchType('')}
>
{!props.isMobileView && renderHintPopover()}
{!isMobileView && renderHintPopover()}
</SearchBar>
</>
);
// when inserted in RHSSearchNav component, just return SearchBar
if (!props.isSideBarRight) {
if (!isSideBarRight) {
if (hideSearchBar) {
return (
<HeaderIconWrapper
@@ -571,34 +536,31 @@ const Search: React.FC<Props> = (props: Props): JSX.Element => {
<div className='search-bar__container channel-header alt'>
<div className='sidebar-right__table'>
{renderSearchBar()}
{renderMentionButton()}
{renderFlagBtn()}
<UserGuideDropdown/>
</div>
</div>
)}
{props.searchVisible ? (
{searchVisible ? (
<SearchResults
isMentionSearch={props.isMentionSearch}
isFlaggedPosts={props.isFlaggedPosts}
isPinnedPosts={props.isPinnedPosts}
isChannelFiles={props.isChannelFiles}
isMentionSearch={isMentionSearch}
isFlaggedPosts={isFlaggedPosts}
isPinnedPosts={isPinnedPosts}
isChannelFiles={isChannelFiles}
shrink={handleShrink}
channelDisplayName={props.channelDisplayName}
isOpened={props.isSideBarRightOpen}
channelDisplayName={channelDisplayName}
isOpened={isSideBarRightOpen}
updateSearchTerms={handleAddSearchTerm}
updateSearchTeam={handleUpdateSearchTeamFromResult}
handleSearchHintSelection={handleSearchHintSelection}
isSideBarExpanded={props.isRhsExpanded}
getMorePostsForSearch={getMorePostsForSearch}
getMoreFilesForSearch={getMoreFilesForSearch}
isSideBarExpanded={isRhsExpanded}
getMorePostsForSearch={getMorePostsForSearchCallback}
getMoreFilesForSearch={getMoreFilesForSearchCallback}
setSearchFilterType={handleSetSearchFilter}
searchFilterType={searchFilterType}
setSearchType={(value: SearchType) => actions.updateSearchType(value)}
setSearchType={(value: SearchType) => updateSearchType(value)}
searchType={searchType || 'messages'}
crossTeamSearchEnabled={props.crossTeamSearchEnabled}
crossTeamSearchEnabled={crossTeamSearchEnabled}
/>
) : props.children}
) : children}
</div>
);
};

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

@@ -24,7 +24,6 @@ export type OwnProps = {
export type StateProps = {
isRhsExpanded: boolean;
isRhsOpen: boolean;
isSearchingTerm: boolean;
searchTerms: string;
searchTeam: string;
@@ -48,8 +47,6 @@ export type DispatchProps = {
updateSearchType: (searchType: string) => Action;
showSearchResults: (isMentionSearch: boolean) => unknown;
showChannelFiles: (channelId: string) => void;
showMentions: () => void;
showFlaggedPosts: () => void;
setRhsExpanded: (expanded: boolean) => Action;
closeRightHandSide: () => void;
autocompleteChannelsForSearch: (term: string, teamId: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void) => void;

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

@@ -1,118 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserGuideDropdown should match snapshot 1`] = `
<MenuWrapper
animationComponent={[Function]}
className="userGuideHelp"
onToggle={[Function]}
>
<WithTooltip
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Help"
id="channel_header.userHelpGuide"
/>
}
>
<button
aria-expanded="true"
className="channel-header__icon"
id="channelHeaderUserGuideButton"
type="button"
>
<UserGuideIcon
className="icon"
/>
</button>
</WithTooltip>
<Menu
ariaLabel="Add Channel Dropdown"
id="AddChannelDropdown"
openLeft={true}
openUp={false}
>
<Memo(MenuGroup)>
<MenuItemExternalLink
id="askTheCommunityLink"
onClick={[Function]}
show={true}
text="Ask the community"
url="https://mattermost.com/pl/default-ask-mattermost-community/"
/>
<MenuItemExternalLink
id="helpResourcesLink"
show={true}
text="Help resources"
url="helpLink"
/>
<MenuItemExternalLink
id="reportAProblemLink"
show={true}
text="Report a problem"
url="reportAProblemLink"
/>
<MenuItemAction
id="keyboardShortcuts"
onClick={[Function]}
show={true}
text="Keyboard shortcuts"
/>
</Memo(MenuGroup)>
</Menu>
</MenuWrapper>
`;
exports[`UserGuideDropdown should match snapshot for false of enableAskCommunityLink 1`] = `
<MenuWrapper
animationComponent={[Function]}
className="userGuideHelp"
onToggle={[Function]}
>
<WithTooltip
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Help"
id="channel_header.userHelpGuide"
/>
}
>
<button
aria-expanded="true"
className="channel-header__icon"
id="channelHeaderUserGuideButton"
type="button"
>
<UserGuideIcon
className="icon"
/>
</button>
</WithTooltip>
<Menu
ariaLabel="Add Channel Dropdown"
id="AddChannelDropdown"
openLeft={true}
openUp={false}
>
<Memo(MenuGroup)>
<MenuItemExternalLink
id="helpResourcesLink"
show={true}
text="Help resources"
url="helpLink"
/>
<MenuItemExternalLink
id="reportAProblemLink"
show={true}
text="Report a problem"
url="reportAProblemLink"
/>
<MenuItemAction
id="keyboardShortcuts"
onClick={[Function]}
show={true}
text="Keyboard shortcuts"
/>
</Memo(MenuGroup)>
</Menu>
</MenuWrapper>
`;

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

@@ -1,38 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import type {ConnectedProps} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {openModal} from 'actions/views/modals';
import type {GlobalState} from 'types/store';
import UserGuideDropdown from './user_guide_dropdown';
function mapStateToProps(state: GlobalState) {
const {HelpLink, ReportAProblemLink, EnableAskCommunityLink} = getConfig(state);
return {
helpLink: HelpLink!,
reportAProblemLink: ReportAProblemLink!,
enableAskCommunityLink: EnableAskCommunityLink!,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
openModal,
}, dispatch),
};
}
const connector = connect(mapStateToProps, mapDispatchToProps);
export type PropsFromRedux = ConnectedProps<typeof connector>;
export default connector(UserGuideDropdown);

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

@@ -1,82 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {trackEvent} from 'actions/telemetry_actions.jsx';
import Menu from 'components/widgets/menu/menu';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
import UserGuideDropdown from './user_guide_dropdown';
jest.mock('actions/telemetry_actions.jsx', () => {
const original = jest.requireActual('actions/telemetry_actions.jsx');
return {
...original,
trackEvent: jest.fn(),
};
});
describe('UserGuideDropdown', () => {
const baseProps = {
helpLink: 'helpLink',
reportAProblemLink: 'reportAProblemLink',
enableAskCommunityLink: 'true',
actions: {
openModal: jest.fn(),
},
};
test('should match snapshot', () => {
const wrapper = shallowWithIntl(
<UserGuideDropdown {...baseProps}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot for false of enableAskCommunityLink', () => {
const props = {
...baseProps,
enableAskCommunityLink: 'false',
};
const wrapper = shallowWithIntl(
<UserGuideDropdown {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('Should set state buttonActive on toggle of MenuWrapper', () => {
const wrapper = shallowWithIntl(
<UserGuideDropdown {...baseProps}/>,
);
expect(wrapper.state('buttonActive')).toBe(false);
wrapper.find(MenuWrapper).prop('onToggle')!(true);
expect(wrapper.state('buttonActive')).toBe(true);
});
test('Should set state buttonActive on toggle of MenuWrapper', () => {
const wrapper = shallowWithIntl(
<UserGuideDropdown {...baseProps}/>,
);
wrapper.find(Menu.ItemAction).prop('onClick')({preventDefault: jest.fn()});
expect(baseProps.actions.openModal).toHaveBeenCalled();
});
test('Should call for track event on click of askTheCommunityLink', () => {
const wrapper = shallowWithIntl(
<UserGuideDropdown {...baseProps}/>,
);
wrapper.find(Menu.ItemExternalLink).find('#askTheCommunityLink').prop('onClick')!({} as unknown as React.MouseEvent);
expect(trackEvent).toBeCalledWith('ui', 'help_ask_the_community');
});
});

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

@@ -1,126 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {FormattedMessage, injectIntl} from 'react-intl';
import type {WrappedComponentProps} from 'react-intl';
import {trackEvent} from 'actions/telemetry_actions';
import KeyboardShortcutsModal from 'components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal';
import UserGuideIcon from 'components/widgets/icons/user_guide_icon';
import Menu from 'components/widgets/menu/menu';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import WithTooltip from 'components/with_tooltip';
import {ModalIdentifiers} from 'utils/constants';
import type {PropsFromRedux} from './index';
const askTheCommunityUrl = 'https://mattermost.com/pl/default-ask-mattermost-community/';
type Props = PropsFromRedux & WrappedComponentProps
type State = {
buttonActive: boolean;
};
class UserGuideDropdown extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
buttonActive: false,
};
}
openKeyboardShortcutsModal = (e: MouseEvent) => {
e.preventDefault();
this.props.actions.openModal({
modalId: ModalIdentifiers.KEYBOARD_SHORTCUTS_MODAL,
dialogType: KeyboardShortcutsModal,
});
};
buttonToggleState = (menuActive: boolean) => {
this.setState({
buttonActive: menuActive,
});
};
askTheCommunityClick = () => {
trackEvent('ui', 'help_ask_the_community');
};
renderDropdownItems = (): React.ReactNode => {
const {intl} = this.props;
return (
<Menu.Group>
{this.props.enableAskCommunityLink === 'true' && (
<Menu.ItemExternalLink
id='askTheCommunityLink'
url={askTheCommunityUrl}
text={intl.formatMessage({id: 'userGuideHelp.askTheCommunity', defaultMessage: 'Ask the community'})}
onClick={this.askTheCommunityClick}
/>
)}
<Menu.ItemExternalLink
id='helpResourcesLink'
url={this.props.helpLink}
text={intl.formatMessage({id: 'userGuideHelp.helpResources', defaultMessage: 'Help resources'})}
/>
<Menu.ItemExternalLink
id='reportAProblemLink'
url={this.props.reportAProblemLink}
text={intl.formatMessage({id: 'userGuideHelp.reportAProblem', defaultMessage: 'Report a problem'})}
/>
<Menu.ItemAction
id='keyboardShortcuts'
onClick={this.openKeyboardShortcutsModal}
text={intl.formatMessage({id: 'userGuideHelp.keyboardShortcuts', defaultMessage: 'Keyboard shortcuts'})}
/>
</Menu.Group>
);
};
render() {
const {intl} = this.props;
const tooltipText = (
<FormattedMessage
id={'channel_header.userHelpGuide'}
defaultMessage='Help'
/>
);
return (
<MenuWrapper
className='userGuideHelp'
onToggle={this.buttonToggleState}
>
<WithTooltip
title={this.state.buttonActive ? '' : tooltipText}
>
<button
id='channelHeaderUserGuideButton'
className={classNames('channel-header__icon', {'channel-header__icon--active': this.state.buttonActive})}
type='button'
aria-expanded='true'
>
<UserGuideIcon className='icon'/>
</button>
</WithTooltip>
<Menu
openLeft={true}
openUp={false}
id='AddChannelDropdown'
ariaLabel={intl.formatMessage({id: 'sidebar_left.add_channel_dropdown.dropdownAriaLabel', defaultMessage: 'Add Channel Dropdown'})}
>
{this.renderDropdownItems()}
</Menu>
</MenuWrapper>
);
}
}
export default injectIntl(UserGuideDropdown);

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

@@ -2577,8 +2577,22 @@
"admin.support.helpTitle": "Help Link:",
"admin.support.privacyDesc": "The URL for the Privacy link on the login and sign-up pages. If this field is empty, the Privacy link is hidden from users.",
"admin.support.privacyTitle": "Privacy Policy Link:",
"admin.support.problemDesc": "The URL for the Report a Problem link in the Help Menu. If this field is empty, the link is removed from the Help Menu.",
"admin.support.problemTitle": "Report a Problem Link:",
"admin.support.problemAllowDownloadDescription": "When enabled, users can download app logs for troubleshooting. If a Report a Problem link is shown, logs can be downloaded as part of that flow; if the Report a Problem link is hidden, logs remain accessible as a separate option.",
"admin.support.problemAllowDownloadTitle": "Allow Mobile App Log Downloads:",
"admin.support.problemType.customLink": "Custom link",
"admin.support.problemType.defaultLink": "Default link",
"admin.support.problemType.email": "Email address",
"admin.support.problemType.hide": "Hide link",
"admin.support.reportAProblemDefaultLinkDescription": "Users will be directed to this link when they choose Report a Problem.",
"admin.support.reportAProblemDefaultLinkTitle": "Default Report a Problem Link:",
"admin.support.reportAProblemEmailDescription": "Enter the email address that users will be prompted to send a message to when they choose Report a Problem.",
"admin.support.reportAProblemEmailError": "Email is required",
"admin.support.reportAProblemEmailTitle": "Report a Problem Email Address:",
"admin.support.reportAProblemLinkDescription": "Enter the URL that users will be directed to when they choose Report a Problem.",
"admin.support.reportAProblemLinkError": "Link is required",
"admin.support.reportAProblemLinkTitle": "Custom Report a Problem Link:",
"admin.support.reportAProblemTypeDescription": "Select how the Report a Problem option behaves. Choosing Custom link or Email address allows you to provide a URL or address in the next field. Hide link removes the Report a Problem option from the app.",
"admin.support.reportAProblemTypeTitle": "Report a Problem:",
"admin.support.termsDesc": "Link to the terms under which users may use your online service. By default, this includes the \"Mattermost Acceptable Use Policy\" explaining the terms under which Mattermost software is provided to end users. If you change the default link to add your own terms for using the service you provide, your new terms must include a link to the default terms so end users are aware of the Mattermost Acceptable Use Policy for Mattermost software.",
"admin.support.termsOfServiceReAcceptanceHelp": "The number of days before Terms of Service acceptance expires, and the terms must be re-accepted.",
"admin.support.termsOfServiceReAcceptanceTitle": "Re-Acceptance Period:",
@@ -6067,7 +6081,6 @@
"userAccountMenu.setCustomStatusMenuItem.noStatusSet": "Set custom status",
"userAccountMenu.setCustomStatusMenuItem.noStatusTextSet": "Set custom status text",
"userGuideHelp.askTheCommunity": "Ask the community",
"userGuideHelp.helpResources": "Help resources",
"userGuideHelp.keyboardShortcuts": "Keyboard shortcuts",
"userGuideHelp.mattermostUserGuide": "Mattermost user guide",
"userGuideHelp.reportAProblem": "Report a problem",

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

@@ -0,0 +1,177 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {GlobalState} from '@mattermost/types/store';
import {getBrowserInfo} from 'mattermost-redux/utils/browser_info';
import {getReportAProblemLink, getSystemInfoMailtoLink} from './report_a_problem';
jest.mock('mattermost-redux/utils/browser_info', () => ({
getBrowserInfo: jest.fn().mockReturnValue({browser: 'Chrome', browserVersion: '1.0.0'}),
getPlatformInfo: jest.fn().mockReturnValue('macOS'),
}));
describe('getReportAProblemLink', () => {
it('should return empty when invalid type', () => {
const state = {
entities: {
general: {
config: {
ReportAProblemType: 'invalid',
ReportAProblemLink: 'https://example.com/report',
ReportAProblemMail: 'test@example.com',
},
},
},
} as unknown as GlobalState;
expect(getReportAProblemLink(state)).toEqual('');
});
it('should return the value of the link', () => {
const state = {
entities: {
general: {
config: {
ReportAProblemType: 'link',
ReportAProblemLink: 'https://example.com/report',
ReportAProblemMail: 'test@example.com',
},
},
},
} as unknown as GlobalState;
expect(getReportAProblemLink(state)).toEqual('https://example.com/report');
state.entities.general.config.ReportAProblemLink = 'https://example.com/new-report';
expect(getReportAProblemLink(state)).toEqual('https://example.com/new-report');
});
it('should return the value of the mail', () => {
const state = {
entities: {
users: {
currentUserId: '123',
},
teams: {
currentTeamId: '456',
},
general: {
config: {
ReportAProblemType: 'email',
ReportAProblemLink: 'https://example.com/report',
ReportAProblemMail: 'test@example.com',
Version: '1.0.0',
BuildNumber: '12345',
SiteName: 'Example',
},
},
},
} as unknown as GlobalState;
const link = getReportAProblemLink(state);
expect(link).toContain(`mailto:test@example.com?subject=${encodeURIComponent('Problem with Example app')}&body=${encodeURIComponent('System Information:')}`);
expect(link).toContain(encodeURIComponent('- User ID: 123'));
expect(link).toContain(encodeURIComponent('- Team ID: 456'));
expect(link).toContain(encodeURIComponent('- Server Version: 1.0.0 (12345)'));
expect(link).toContain(encodeURIComponent('- Browser: Chrome 1.0.0'));
expect(link).toContain(encodeURIComponent('- Platform: macOS'));
});
it('should return the default value if licensed', () => {
const state = {
entities: {
general: {
config: {
ReportAProblemType: 'default',
ReportAProblemLink: 'https://example.com/report',
ReportAProblemMail: 'test@example.com',
},
license: {
IsLicensed: 'true',
},
},
},
} as unknown as GlobalState;
expect(getReportAProblemLink(state)).toContain('https://mattermost.com/pl/report_a_problem_licensed');
});
it('should return the default value if unlicensed', () => {
const state = {
entities: {
general: {
config: {
ReportAProblemType: 'default',
ReportAProblemLink: 'https://example.com/report',
ReportAProblemMail: 'test@example.com',
},
license: {
IsLicensed: 'false',
},
},
},
} as unknown as GlobalState;
expect(getReportAProblemLink(state)).toContain('https://mattermost.com/pl/report_a_problem_unlicensed');
});
});
describe('getSystemInfoMailtoLink', () => {
it('should return the link with the correct data', () => {
const state = {
entities: {
users: {
currentUserId: '123',
},
teams: {
currentTeamId: '456',
},
general: {
config: {
Version: '1.0.0',
BuildNumber: '12345',
SiteName: 'Example',
},
},
},
} as unknown as GlobalState;
const link = getSystemInfoMailtoLink(state, 'test@example.com');
expect(link).toContain(`mailto:test@example.com?subject=${encodeURIComponent('Problem with Example app')}&body=${encodeURIComponent('System Information:')}`);
expect(link).toContain(encodeURIComponent('- User ID: 123'));
expect(link).toContain(encodeURIComponent('- Team ID: 456'));
expect(link).toContain(encodeURIComponent('- Server Version: 1.0.0 (12345)'));
expect(link).toContain(encodeURIComponent('- Browser: Chrome 1.0.0'));
expect(link).toContain(encodeURIComponent('- Platform: macOS'));
});
it('should only execute once if called with the same values', () => {
const state = {
entities: {
users: {
currentUserId: '123',
},
teams: {
currentTeamId: '456',
},
general: {
config: {
Version: '1.0.0',
BuildNumber: '12345',
SiteName: 'Example',
},
},
},
} as unknown as GlobalState;
getSystemInfoMailtoLink(state, 'test@example1.com');
expect(getBrowserInfo).toHaveBeenCalledTimes(1);
getSystemInfoMailtoLink(state, 'test@example1.com');
expect(getBrowserInfo).toHaveBeenCalledTimes(1); // No new calls
getSystemInfoMailtoLink(state, 'test@example2.com');
expect(getBrowserInfo).toHaveBeenCalledTimes(2);
});
});

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

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {GlobalState} from '@mattermost/types/store';
import {createSelector} from 'mattermost-redux/selectors/create_selector';
import {getBrowserInfo, getPlatformInfo} from 'mattermost-redux/utils/browser_info';
import {getConfig, getLicense} from './general';
import {getCurrentTeamId} from './teams';
import {getCurrentUserId} from './users';
export function getReportAProblemLink(state: GlobalState): string {
const config = getConfig(state);
const type = config.ReportAProblemType;
switch (type) {
case 'email':
return getSystemInfoMailtoLink(state, config.ReportAProblemMail);
case 'link':
if (config.ReportAProblemLink) {
return config.ReportAProblemLink;
}
// falls through
case 'default': {
const isLicensed = getLicense(state).IsLicensed === 'true';
if (isLicensed) {
return 'https://mattermost.com/pl/report_a_problem_licensed';
}
return 'https://mattermost.com/pl/report_a_problem_unlicensed';
}
}
return '';
}
export const getSystemInfoMailtoLink = createSelector(
'getSystemInfoMailtoLink',
getCurrentUserId,
getCurrentTeamId,
(state: GlobalState) => getConfig(state).Version,
(state: GlobalState) => getConfig(state).BuildNumber,
(state: GlobalState) => getConfig(state).SiteName,
(state: GlobalState, supportEmail: string | undefined) => supportEmail,
(currentUserId: string, currentTeamId: string, version: string | undefined, buildNumber: string | undefined, siteName: string | undefined, supportEmail: string | undefined) => {
const {browser, browserVersion} = getBrowserInfo();
const platformName = getPlatformInfo();
const subject = `Problem with ${siteName || 'Mattermost'} app`;
const body = `
System Information:
- User ID: ${currentUserId}
- Team ID: ${currentTeamId}
- Server Version: ${version} (${buildNumber})
- Browser: ${browser} ${browserVersion}
- Platform: ${platformName}
`.trim();
return `mailto:${supportEmail || ''}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
},
);

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

@@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getBrowserInfo, getPlatformInfo} from './browser_info';
describe('utils/browser_info', () => {
const originalNavigator = window.navigator;
beforeEach(() => {
// Create a mock navigator object
Object.defineProperty(window, 'navigator', {
value: {
userAgent: '',
platform: '',
},
writable: true,
});
});
afterEach(() => {
// Restore the original navigator
Object.defineProperty(window, 'navigator', {
value: originalNavigator,
writable: true,
});
});
describe('getBrowserInfo', () => {
const browserTestCases = [
{
name: 'Mattermost Desktop App',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.83 Electron/34.0.1 Safari/537.36 Mattermost/34.0.1',
expectedBrowser: 'Mattermost Desktop App',
expectedVersion: '34.0.1',
},
{
name: 'Edge (Legacy)',
userAgent: 'Mozilla/5.0 (Windows NT 10.0) Edge/42.0',
expectedBrowser: 'Edge',
expectedVersion: '42',
},
{
name: 'Edge Chromium',
userAgent: 'Mozilla/5.0 (Windows NT 10.0) Edg/92.0.234.1',
expectedBrowser: 'Edge Chromium',
expectedVersion: '92',
},
{
name: 'Chrome',
userAgent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/92.0.4515.131',
expectedBrowser: 'Chrome',
expectedVersion: '92',
},
{
name: 'Opera',
userAgent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/92.0.4515.131 OPR/77.0.4054.277',
expectedBrowser: 'Opera',
expectedVersion: '77',
},
{
name: 'Safari',
userAgent: 'Mozilla/5.0 (Macintosh) Version/14.1 Safari/605.1.15',
expectedBrowser: 'Safari',
expectedVersion: '14',
},
{
name: 'Firefox',
userAgent: 'Mozilla/5.0 (Windows NT 10.0) Firefox/90.0',
expectedBrowser: 'Firefox',
expectedVersion: '90',
},
{
name: 'Unknown Browser',
userAgent: 'Some Unknown Browser',
expectedBrowser: 'Unknown',
expectedVersion: 'Unknown',
},
];
test.each(browserTestCases)(
'should detect $name',
({userAgent, expectedBrowser, expectedVersion}) => {
// @ts-expect-error we can override the userAgent in tests
window.navigator.userAgent = userAgent;
const {browser, browserVersion} = getBrowserInfo();
expect(browser).toBe(expectedBrowser);
expect(browserVersion).toBe(expectedVersion);
},
);
});
describe('getPlatformInfo', () => {
const platformTestCases = [
{
name: 'Windows using platform',
platform: 'Win32',
userAgent: '',
expectedPlatform: 'Windows',
},
{
name: 'MacOS using platform',
platform: 'MacIntel',
userAgent: '',
expectedPlatform: 'MacOS',
},
{
name: 'Linux using platform',
platform: 'Linux x86_64',
userAgent: '',
expectedPlatform: 'Linux',
},
{
name: 'Windows using userAgent',
platform: '',
userAgent: 'Mozilla/5.0 (Windows NT 10.0)',
expectedPlatform: 'Windows',
},
{
name: 'MacOS using userAgent',
platform: '',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
expectedPlatform: 'MacOS',
},
{
name: 'Linux using userAgent',
platform: '',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64)',
expectedPlatform: 'Linux',
},
{
name: 'Unknown Platform',
platform: '',
userAgent: 'Some Unknown Platform',
expectedPlatform: 'Unknown',
},
];
test.each(platformTestCases)(
'should detect $name',
({platform, userAgent, expectedPlatform}) => {
// @ts-expect-error we can override the platform in tests
window.navigator.platform = platform;
// @ts-expect-error we can override the userAgent in tests
window.navigator.userAgent = userAgent;
expect(getPlatformInfo()).toBe(expectedPlatform);
},
);
});
});

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

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function getBrowserInfo() {
const userAgent = window.navigator.userAgent.toLowerCase();
let browser = 'Unknown';
let browserVersion = 'Unknown';
// Check if it's Mattermost Desktop App first
if (userAgent.includes('mattermost')) {
browser = 'Mattermost Desktop App';
const match = userAgent.match(/mattermost\/(\d+(\.\d+)*)/i);
if (match && match[1]) {
browserVersion = match[1];
}
return {browser, browserVersion};
}
// Simple browser detection - order matters!
if (userAgent.includes('edge/')) {
browser = 'Edge';
} else if (userAgent.includes('edg/')) {
browser = 'Edge Chromium';
} else if (userAgent.includes('chrome/')) {
if (userAgent.includes('opr/')) {
browser = 'Opera';
} else {
browser = 'Chrome';
}
} else if (userAgent.includes('safari/') && userAgent.includes('version/')) {
browser = 'Safari';
} else if (userAgent.includes('firefox/')) {
browser = 'Firefox';
}
// Get browser version
let match;
if (browser === 'Edge') {
match = userAgent.match(/edge\/(\d+)/i);
} else if (browser === 'Edge Chromium') {
match = userAgent.match(/edg\/(\d+)/i);
} else if (browser === 'Opera') {
match = userAgent.match(/opr\/(\d+)/i);
} else if (browser === 'Safari') {
match = userAgent.match(/version\/(\d+)/i);
} else {
match = userAgent.match(/(firefox|chrome)\/(\d+)/i);
if (match) {
match[1] = match[2]; // Align with other matches where version is in group 1
}
}
if (match && match[1]) {
browserVersion = match[1];
}
return {browser, browserVersion};
}
export function getPlatformInfo() {
// Casting to undefined in case it is deprecated in any browser
const platform = window.navigator.platform as string | undefined;
const userAgent = window.navigator.userAgent.toLowerCase();
let platformName = 'Unknown';
// First try using platform
if (platform) {
if (platform.toLowerCase().includes('win')) {
platformName = 'Windows';
} else if (platform.toLowerCase().includes('mac')) {
platformName = 'MacOS';
} else if (platform.toLowerCase().includes('linux')) {
platformName = 'Linux';
}
}
// Fallback to userAgent if platform didn't work
if (platformName === 'Unknown') {
if (userAgent.includes('windows')) {
platformName = 'Windows';
} else if (userAgent.includes('mac os x')) {
platformName = 'MacOS';
} else if (userAgent.includes('linux')) {
platformName = 'Linux';
}
}
return platformName;
}

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

@@ -168,6 +168,9 @@ export type ClientConfig = {
PostEditTimeLimit: string;
PrivacyPolicyLink: string;
ReportAProblemLink: string;
ReportAProblemType: string;
ReportAProblemMail: string;
AllowDownloadLogs: string;
RequireEmailVerification: string;
RestrictDirectMessage: string;
RunJobs: string;
@@ -627,6 +630,9 @@ export type SupportSettings = {
AboutLink: string;
HelpLink: string;
ReportAProblemLink: string;
ReportAProblemType: string;
ReportAProblemMail: string;
AllowDownloadLogs: boolean;
ForgotPasswordLink: string;
SupportEmail: string;
CustomTermsOfServiceEnabled: boolean;