diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index a738f6c443..3280ffa55e 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -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: diff --git a/e2e-tests/cypress/tests/integration/channels/system_console/site_configuration/link_customization_e20_1_spec.js b/e2e-tests/cypress/tests/integration/channels/system_console/site_configuration/link_customization_e20_1_spec.js index 8e542c5a9e..10bd7234db 100644 --- a/e2e-tests/cypress/tests/integration/channels/system_console/site_configuration/link_customization_e20_1_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/system_console/site_configuration/link_customization_e20_1_spec.js @@ -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 diff --git a/e2e-tests/cypress/tests/support/api/cloud_default_config.json b/e2e-tests/cypress/tests/support/api/cloud_default_config.json index 058de6ed65..1068f39b5f 100644 --- a/e2e-tests/cypress/tests/support/api/cloud_default_config.json +++ b/e2e-tests/cypress/tests/support/api/cloud_default_config.json @@ -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, diff --git a/e2e-tests/cypress/tests/support/api/on_prem_default_config.json b/e2e-tests/cypress/tests/support/api/on_prem_default_config.json index 98bf109362..39d5020769 100644 --- a/e2e-tests/cypress/tests/support/api/on_prem_default_config.json +++ b/e2e-tests/cypress/tests/support/api/on_prem_default_config.json @@ -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, diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index beec47e0f5..73eb0c4a7e 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -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, diff --git a/server/config/client.go b/server/config/client.go index 8197135aa8..2747f02810 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -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) diff --git a/server/config/client_test.go b/server/config/client_test.go index 04537e016f..9cb54b3e50 100644 --- a/server/config/client_test.go +++ b/server/config/client_test.go @@ -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 { diff --git a/server/i18n/en.json b/server/i18n/en.json index 983eca2788..8278441906 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -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'." diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index a946274d6c..f8b70d3d53 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -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{ diff --git a/server/public/model/config.go b/server/public/model/config.go index 165f7b8598..6627e7205d 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -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 } diff --git a/server/public/model/config_test.go b/server/public/model/config_test.go index 3f196e977c..cabc731aa6 100644 --- a/server/public/model/config_test.go +++ b/server/public/model/config_test.go @@ -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) { diff --git a/server/tests/test-config.json b/server/tests/test-config.json index a9c4e799b6..1e0164fd62 100644 --- a/server/tests/test-config.json +++ b/server/tests/test-config.json @@ -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" }, diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index e988a4c262..02e225402a 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -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', diff --git a/webapp/channels/src/components/common/hooks/use_external_link.test.ts b/webapp/channels/src/components/common/hooks/use_external_link.test.ts index 2d1447ab51..d1688512f6 100644 --- a/webapp/channels/src/components/common/hooks/use_external_link.test.ts +++ b/webapp/channels/src/components/common/hooks/use_external_link.test.ts @@ -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'); + }); }); diff --git a/webapp/channels/src/components/common/hooks/use_external_link.ts b/webapp/channels/src/components/common/hooks/use_external_link.ts index 1fc989e233..2b502e59c8 100644 --- a/webapp/channels/src/components/common/hooks/use_external_link.ts +++ b/webapp/channels/src/components/common/hooks/use_external_link.ts @@ -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]); diff --git a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts index df1f1f08e6..041f1a827d 100644 --- a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts +++ b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts @@ -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), }; } diff --git a/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right_items/index.tsx b/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right_items/index.tsx index cd66511dc8..96034a09a3 100644 --- a/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right_items/index.tsx +++ b/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right_items/index.tsx @@ -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; diff --git a/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right_items/mobile_sidebar_right_items.test.tsx b/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right_items/mobile_sidebar_right_items.test.tsx index 1cd472efea..b54c30c10c 100644 --- a/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right_items/mobile_sidebar_right_items.test.tsx +++ b/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right_items/mobile_sidebar_right_items.test.tsx @@ -18,7 +18,7 @@ describe('MobileSidebarRightItems', () => { appDownloadLink: undefined, experimentalPrimaryTeam: undefined, helpLink: undefined, - reportAProblemLink: undefined, + reportAProblemLink: '', moreTeamsToJoin: false, pluginMenuItems: [], isMentionSearch: false, diff --git a/webapp/channels/src/components/search/index.tsx b/webapp/channels/src/components/search/index.tsx index 3f6b89fca6..7c6942e625 100644 --- a/webapp/channels/src/components/search/index.tsx +++ b/webapp/channels/src/components/search/index.tsx @@ -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, diff --git a/webapp/channels/src/components/search/search.tsx b/webapp/channels/src/components/search/search.tsx index b4afc7a313..09e1a7908c 100644 --- a/webapp/channels/src/components/search/search.tsx +++ b/webapp/channels/src/components/search/search.tsx @@ -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): 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): JSX.Element => { const suggestionProviders = useRef([ 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): 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): 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): 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): 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): 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): JSX.Element => { const handleChange = (e: ChangeEvent): 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): 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): 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): 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): 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): JSX.Element => { setIndexChangedViaKeyPress(false); }; - const searchMentions = (e: MouseEvent): void => { - e.preventDefault(); - if (props.isMentionSearch) { - actions.closeRightHandSide(); - return; - } - actions.showMentions(); - }; - - const getFlagged = (e: MouseEvent): 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 => ( - - - ); - - const renderFlagBtn = (): JSX.Element => ( - - - - ); - const renderHintPopover = (): JSX.Element => { let termsUsed = 0; @@ -470,7 +435,7 @@ const Search: React.FC = (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): JSX.Element => { return ( @@ -502,7 +467,7 @@ const Search: React.FC = (props: Props): JSX.Element => { <>
@@ -524,20 +489,20 @@ const Search: React.FC = (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()} ); // when inserted in RHSSearchNav component, just return SearchBar - if (!props.isSideBarRight) { + if (!isSideBarRight) { if (hideSearchBar) { return ( = (props: Props): JSX.Element => {
{renderSearchBar()} - {renderMentionButton()} - {renderFlagBtn()} -
)} - {props.searchVisible ? ( + {searchVisible ? ( actions.updateSearchType(value)} + setSearchType={(value: SearchType) => updateSearchType(value)} searchType={searchType || 'messages'} - crossTeamSearchEnabled={props.crossTeamSearchEnabled} + crossTeamSearchEnabled={crossTeamSearchEnabled} /> - ) : props.children} + ) : children}
); }; diff --git a/webapp/channels/src/components/search/types.ts b/webapp/channels/src/components/search/types.ts index f0af6c7ccd..fa663bff81 100644 --- a/webapp/channels/src/components/search/types.ts +++ b/webapp/channels/src/components/search/types.ts @@ -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; diff --git a/webapp/channels/src/components/search/user_guide_dropdown/__snapshots__/user_guide_dropdown.test.tsx.snap b/webapp/channels/src/components/search/user_guide_dropdown/__snapshots__/user_guide_dropdown.test.tsx.snap deleted file mode 100644 index f5706045da..0000000000 --- a/webapp/channels/src/components/search/user_guide_dropdown/__snapshots__/user_guide_dropdown.test.tsx.snap +++ /dev/null @@ -1,118 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`UserGuideDropdown should match snapshot 1`] = ` - - - } - > - - - - - - - - - - - -`; - -exports[`UserGuideDropdown should match snapshot for false of enableAskCommunityLink 1`] = ` - - - } - > - - - - - - - - - - -`; diff --git a/webapp/channels/src/components/search/user_guide_dropdown/index.ts b/webapp/channels/src/components/search/user_guide_dropdown/index.ts deleted file mode 100644 index 177ec4960a..0000000000 --- a/webapp/channels/src/components/search/user_guide_dropdown/index.ts +++ /dev/null @@ -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; - -export default connector(UserGuideDropdown); diff --git a/webapp/channels/src/components/search/user_guide_dropdown/user_guide_dropdown.test.tsx b/webapp/channels/src/components/search/user_guide_dropdown/user_guide_dropdown.test.tsx deleted file mode 100644 index 84985c2c23..0000000000 --- a/webapp/channels/src/components/search/user_guide_dropdown/user_guide_dropdown.test.tsx +++ /dev/null @@ -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( - , - ); - - expect(wrapper).toMatchSnapshot(); - }); - - test('should match snapshot for false of enableAskCommunityLink', () => { - const props = { - ...baseProps, - enableAskCommunityLink: 'false', - }; - - const wrapper = shallowWithIntl( - , - ); - - expect(wrapper).toMatchSnapshot(); - }); - - test('Should set state buttonActive on toggle of MenuWrapper', () => { - const wrapper = shallowWithIntl( - , - ); - - 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( - , - ); - - 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( - , - ); - - wrapper.find(Menu.ItemExternalLink).find('#askTheCommunityLink').prop('onClick')!({} as unknown as React.MouseEvent); - expect(trackEvent).toBeCalledWith('ui', 'help_ask_the_community'); - }); -}); diff --git a/webapp/channels/src/components/search/user_guide_dropdown/user_guide_dropdown.tsx b/webapp/channels/src/components/search/user_guide_dropdown/user_guide_dropdown.tsx deleted file mode 100644 index e0dff2db12..0000000000 --- a/webapp/channels/src/components/search/user_guide_dropdown/user_guide_dropdown.tsx +++ /dev/null @@ -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 { - 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 ( - - {this.props.enableAskCommunityLink === 'true' && ( - - )} - - - - - ); - }; - - render() { - const {intl} = this.props; - const tooltipText = ( - - ); - - return ( - - - - - - {this.renderDropdownItems()} - - - ); - } -} - -export default injectIntl(UserGuideDropdown); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 8e9960ed70..0da72f770d 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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", diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/report_a_problem.test.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/report_a_problem.test.ts new file mode 100644 index 0000000000..2671f27d7e --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/report_a_problem.test.ts @@ -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); + }); +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/report_a_problem.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/report_a_problem.ts new file mode 100644 index 0000000000..3ef5baced3 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/report_a_problem.ts @@ -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)}`; + }, +); diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/browser_info.test.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/browser_info.test.ts new file mode 100644 index 0000000000..cacfb5195d --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/browser_info.test.ts @@ -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); + }, + ); + }); +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/browser_info.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/browser_info.ts new file mode 100644 index 0000000000..22b94c0fb5 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/browser_info.ts @@ -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; +} diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index 9bfe2e3292..ad43fd867c 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -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;