* 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 type: string
ReportAProblemLink: ReportAProblemLink:
type: string type: string
ReportAProblemType:
type: string
ReportAProblemMail:
type: string
AllowDownloadLogs:
type: boolean
SupportEmail: SupportEmail:
type: string type: string
GitLabSettings: GitLabSettings:
@@ -2047,6 +2053,12 @@ components:
type: boolean type: boolean
ReportAProblemLink: ReportAProblemLink:
type: boolean type: boolean
ReportAProblemType:
type: boolean
ReportAProblemMail:
type: boolean
AllowDownloadLogs:
type: boolean
SupportEmail: SupportEmail:
type: boolean type: boolean
GitLabSettings: GitLabSettings:

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

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

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

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

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

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

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

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

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

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

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

@@ -319,6 +319,25 @@ func TestGetClientConfig(t *testing.T) {
"GiphySdkKey": model.ServiceSettingsDefaultGiphySdkKeyTest, "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 { for _, testCase := range testCases {

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

@@ -9308,6 +9308,22 @@
"id": "model.config.is_valid.read_timeout.app_error", "id": "model.config.is_valid.read_timeout.app_error",
"translation": "Invalid value for read timeout." "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", "id": "model.config.is_valid.restrict_direct_message.app_error",
"translation": "Invalid direct message restriction. Must be 'any', or 'team'." "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_enabled": *cfg.SupportSettings.CustomTermsOfServiceEnabled,
"custom_terms_of_service_re_acceptance_period": *cfg.SupportSettings.CustomTermsOfServiceReAcceptancePeriod, "custom_terms_of_service_re_acceptance_period": *cfg.SupportSettings.CustomTermsOfServiceReAcceptancePeriod,
"enable_ask_community_link": *cfg.SupportSettings.EnableAskCommunityLink, "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{ configs[TrackConfigLDAP] = map[string]any{

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

@@ -147,6 +147,12 @@ const (
SupportSettingsDefaultSupportEmail = "" SupportSettingsDefaultSupportEmail = ""
SupportSettingsDefaultReAcceptancePeriod = 365 SupportSettingsDefaultReAcceptancePeriod = 365
SupportSettingsReportAProblemTypeLink = "link"
SupportSettingsReportAProblemTypeMail = "email"
SupportSettingsReportAProblemTypeHidden = "hidden"
SupportSettingsReportAProblemTypeDefault = "default"
SupportSettingsDefaultReportAProblemType = SupportSettingsReportAProblemTypeDefault
LdapSettingsDefaultFirstNameAttribute = "" LdapSettingsDefaultFirstNameAttribute = ""
LdapSettingsDefaultLastNameAttribute = "" LdapSettingsDefaultLastNameAttribute = ""
LdapSettingsDefaultEmailAttribute = "" LdapSettingsDefaultEmailAttribute = ""
@@ -2146,6 +2152,9 @@ type SupportSettings struct {
AboutLink *string `access:"site_customization,write_restrictable,cloud_restrictable"` AboutLink *string `access:"site_customization,write_restrictable,cloud_restrictable"`
HelpLink *string `access:"site_customization"` HelpLink *string `access:"site_customization"`
ReportAProblemLink *string `access:"site_customization,write_restrictable,cloud_restrictable"` 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"` ForgotPasswordLink *string `access:"site_customization,write_restrictable,cloud_restrictable"`
SupportEmail *string `access:"site_notifications"` SupportEmail *string `access:"site_notifications"`
CustomTermsOfServiceEnabled *bool `access:"compliance_custom_terms_of_service"` CustomTermsOfServiceEnabled *bool `access:"compliance_custom_terms_of_service"`
@@ -2194,6 +2203,18 @@ func (s *SupportSettings) SetDefaults() {
s.ReportAProblemLink = NewPointer(SupportSettingsDefaultReportAProblemLink) 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) { if !isSafeLink(s.ForgotPasswordLink) {
*s.ForgotPasswordLink = "" *s.ForgotPasswordLink = ""
} }
@@ -3987,6 +4008,26 @@ func (o *Config) IsValid() *AppError {
return appErr 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 return nil
} }

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

@@ -61,6 +61,52 @@ func TestConfigDefaults(t *testing.T) {
c.SetDefaults() c.SetDefaults()
recursivelyUninitialize(&c, "config", reflect.ValueOf(&c).Elem()) 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) { func TestConfigEmptySiteName(t *testing.T) {

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

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

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

@@ -2206,13 +2206,97 @@ const AdminDefinition: AdminDefinitionType = {
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.CUSTOMIZATION)), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.CUSTOMIZATION)),
isHidden: it.configIsTrue('ExperimentalSettings', 'RestrictSystemAdmin'), 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', type: 'text',
key: 'SupportSettings.ReportAProblemLink', key: 'SupportSettings.ReportAProblemLink',
label: defineMessage({id: 'admin.support.problemTitle', defaultMessage: 'Report a Problem Link:'}), label: defineMessage({id: 'admin.support.reportAProblemLinkTitle', defaultMessage: 'Custom 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.'}), 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.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.CUSTOMIZATION)), isDisabled: it.any(
isHidden: it.configIsTrue('ExperimentalSettings', 'RestrictSystemAdmin'), 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', type: 'text',

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

@@ -38,6 +38,13 @@ describe('useExternalLink', () => {
expect(queryParams).toEqual({}); 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', () => { it('all base queries are set correctly', () => {
const url = 'https://www.mattermost.com/some/url'; const url = 'https://www.mattermost.com/some/url';
const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url), getBaseState()); const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url), getBaseState());
@@ -129,4 +136,9 @@ describe('useExternalLink', () => {
expect(firstHref).toBe(secondHref); expect(firstHref).toBe(secondHref);
expect(firstParams).toBe(secondParams); 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'); const isCloud = useSelector((state: GlobalState) => getLicense(state).Cloud === 'true');
return useMemo(() => { return useMemo(() => {
if (!href?.includes('mattermost.com')) { if (!href?.includes('mattermost.com') || href?.startsWith('mailto:')) {
return [href, {}]; return [href, {}];
} }
@@ -40,7 +40,7 @@ export function useExternalLink(href: string, location: string = '', overwriteQu
...overwriteQueryParams, ...overwriteQueryParams,
...existingQueryParamsObj, ...existingQueryParamsObj,
}; };
parsedUrl.search = new URLSearchParams(queryParams).toString(); parsedUrl.search = Object.entries(queryParams).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');
return [parsedUrl.toString(), queryParams]; return [parsedUrl.toString(), queryParams];
}, [href, isCloud, location, overwriteQueryParams, telemetryId, userId]); }, [href, isCloud, location, overwriteQueryParams, telemetryId, userId]);

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

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

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

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

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

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

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

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

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

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

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

@@ -24,7 +24,6 @@ export type OwnProps = {
export type StateProps = { export type StateProps = {
isRhsExpanded: boolean; isRhsExpanded: boolean;
isRhsOpen: boolean;
isSearchingTerm: boolean; isSearchingTerm: boolean;
searchTerms: string; searchTerms: string;
searchTeam: string; searchTeam: string;
@@ -48,8 +47,6 @@ export type DispatchProps = {
updateSearchType: (searchType: string) => Action; updateSearchType: (searchType: string) => Action;
showSearchResults: (isMentionSearch: boolean) => unknown; showSearchResults: (isMentionSearch: boolean) => unknown;
showChannelFiles: (channelId: string) => void; showChannelFiles: (channelId: string) => void;
showMentions: () => void;
showFlaggedPosts: () => void;
setRhsExpanded: (expanded: boolean) => Action; setRhsExpanded: (expanded: boolean) => Action;
closeRightHandSide: () => void; closeRightHandSide: () => void;
autocompleteChannelsForSearch: (term: string, teamId: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void) => 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.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.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.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.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.problemTitle": "Report a Problem Link:", "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.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.termsOfServiceReAcceptanceHelp": "The number of days before Terms of Service acceptance expires, and the terms must be re-accepted.",
"admin.support.termsOfServiceReAcceptanceTitle": "Re-Acceptance Period:", "admin.support.termsOfServiceReAcceptanceTitle": "Re-Acceptance Period:",
@@ -6067,7 +6081,6 @@
"userAccountMenu.setCustomStatusMenuItem.noStatusSet": "Set custom status", "userAccountMenu.setCustomStatusMenuItem.noStatusSet": "Set custom status",
"userAccountMenu.setCustomStatusMenuItem.noStatusTextSet": "Set custom status text", "userAccountMenu.setCustomStatusMenuItem.noStatusTextSet": "Set custom status text",
"userGuideHelp.askTheCommunity": "Ask the community", "userGuideHelp.askTheCommunity": "Ask the community",
"userGuideHelp.helpResources": "Help resources",
"userGuideHelp.keyboardShortcuts": "Keyboard shortcuts", "userGuideHelp.keyboardShortcuts": "Keyboard shortcuts",
"userGuideHelp.mattermostUserGuide": "Mattermost user guide", "userGuideHelp.mattermostUserGuide": "Mattermost user guide",
"userGuideHelp.reportAProblem": "Report a problem", "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; PostEditTimeLimit: string;
PrivacyPolicyLink: string; PrivacyPolicyLink: string;
ReportAProblemLink: string; ReportAProblemLink: string;
ReportAProblemType: string;
ReportAProblemMail: string;
AllowDownloadLogs: string;
RequireEmailVerification: string; RequireEmailVerification: string;
RestrictDirectMessage: string; RestrictDirectMessage: string;
RunJobs: string; RunJobs: string;
@@ -627,6 +630,9 @@ export type SupportSettings = {
AboutLink: string; AboutLink: string;
HelpLink: string; HelpLink: string;
ReportAProblemLink: string; ReportAProblemLink: string;
ReportAProblemType: string;
ReportAProblemMail: string;
AllowDownloadLogs: boolean;
ForgotPasswordLink: string; ForgotPasswordLink: string;
SupportEmail: string; SupportEmail: string;
CustomTermsOfServiceEnabled: boolean; CustomTermsOfServiceEnabled: boolean;