diff --git a/server/Makefile b/server/Makefile index 8b8c77af2ee..a64f28b0411 100644 --- a/server/Makefile +++ b/server/Makefile @@ -156,7 +156,7 @@ TEMPLATES_DIR=templates PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:) PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.4 PLUGIN_PACKAGES += mattermost-plugin-github-v2.6.0 -PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.12.0 +PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.12.1 PLUGIN_PACKAGES += mattermost-plugin-jira-v4.5.1 PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.8.0 PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0 diff --git a/server/channels/jobs/import_process/worker.go b/server/channels/jobs/import_process/worker.go index f9e974b8405..df1113b8479 100644 --- a/server/channels/jobs/import_process/worker.go +++ b/server/channels/jobs/import_process/worker.go @@ -124,8 +124,16 @@ func MakeWorker(jobServer *jobs.JobServer, app AppIface) *jobs.SimpleWorker { defer jsonFile.Close() extractContent := job.Data["extract_content"] == "true" + + numWorkers := runtime.NumCPU() + if workersStr, ok := job.Data["workers"]; ok { + if n, err := strconv.Atoi(workersStr); err == nil && n > 0 { + numWorkers = n + } + } + // do the actual import. - lineNumber, appErr := app.BulkImportWithPath(appContext, jsonFile, importZipReader, false, extractContent, runtime.NumCPU(), model.ExportDataDir) + lineNumber, appErr := app.BulkImportWithPath(appContext, jsonFile, importZipReader, false, extractContent, numWorkers, model.ExportDataDir) if appErr != nil { job.Data["line_number"] = strconv.Itoa(lineNumber) return appErr diff --git a/server/cmd/mmctl/commands/import.go b/server/cmd/mmctl/commands/import.go index 8283127dd0a..cb6173d35e8 100644 --- a/server/cmd/mmctl/commands/import.go +++ b/server/cmd/mmctl/commands/import.go @@ -11,6 +11,7 @@ import ( "os" "path" "path/filepath" + "runtime" "strconv" "strings" "text/template" @@ -123,6 +124,7 @@ func init() { ImportProcessCmd.Flags().Bool("bypass-upload", false, "If this is set, the file is not processed from the server, but rather directly read from the filesystem. Works only in --local mode.") ImportProcessCmd.Flags().Bool("extract-content", true, "If this is set, document attachments will be extracted and indexed during the import process. It is advised to disable it to improve performance.") + ImportProcessCmd.Flags().Int("workers", 0, "The number of concurrent import worker goroutines. Controls database load during import. When set to 0 (default), uses the number of CPUs available. Maximum allowed is 4x the CPU count.") ImportListCmd.AddCommand( ImportListAvailableCmd, @@ -310,14 +312,25 @@ func importProcessCmdF(c client.Client, command *cobra.Command, args []string) e } extractContent, _ := command.Flags().GetBool("extract-content") + workers, _ := command.Flags().GetInt("workers") + + maxWorkers := runtime.NumCPU() * 4 + if workers > maxWorkers { + return fmt.Errorf("workers value %d exceeds maximum allowed (%d = 4 * CPU count)", workers, maxWorkers) + } + + jobData := map[string]string{ + "import_file": importFile, + "local_mode": strconv.FormatBool(isLocal && bypassUpload), + "extract_content": strconv.FormatBool(extractContent), + } + if workers > 0 { + jobData["workers"] = strconv.Itoa(workers) + } job, _, err := c.CreateJob(context.TODO(), &model.Job{ Type: model.JobTypeImportProcess, - Data: map[string]string{ - "import_file": importFile, - "local_mode": strconv.FormatBool(isLocal && bypassUpload), - "extract_content": strconv.FormatBool(extractContent), - }, + Data: jobData, }) if err != nil { return fmt.Errorf("failed to create import process job: %w", err) diff --git a/server/cmd/mmctl/commands/import_test.go b/server/cmd/mmctl/commands/import_test.go index 68cbb2242c6..f29433e0d16 100644 --- a/server/cmd/mmctl/commands/import_test.go +++ b/server/cmd/mmctl/commands/import_test.go @@ -10,6 +10,8 @@ import ( "net/http" "os" "path/filepath" + "runtime" + "strconv" "strings" "github.com/pkg/errors" @@ -211,28 +213,85 @@ func (s *MmctlUnitTestSuite) TestImportJobListCmdF() { } func (s *MmctlUnitTestSuite) TestImportProcessCmdF() { - printer.Clean() - importFile := "import.zip" - mockJob := &model.Job{ - Type: model.JobTypeImportProcess, - Data: map[string]string{ - "import_file": importFile, - "local_mode": "false", - "extract_content": "false", - }, - } - - s.client. - EXPECT(). - CreateJob(context.TODO(), mockJob). - Return(mockJob, &model.Response{}, nil). - Times(1) - - err := importProcessCmdF(s.client, &cobra.Command{}, []string{importFile}) - s.Require().Nil(err) - s.Len(printer.GetLines(), 1) - s.Empty(printer.GetErrorLines()) - s.Equal(mockJob, printer.GetLines()[0].(*model.Job)) + s.Run("default workers", func() { + printer.Clean() + importFile := "import.zip" + mockJob := &model.Job{ + Type: model.JobTypeImportProcess, + Data: map[string]string{ + "import_file": importFile, + "local_mode": "false", + "extract_content": "false", + }, + } + + s.client. + EXPECT(). + CreateJob(context.TODO(), mockJob). + Return(mockJob, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("bypass-upload", false, "") + cmd.Flags().Bool("extract-content", false, "") + cmd.Flags().Int("workers", 0, "") + + err := importProcessCmdF(s.client, cmd, []string{importFile}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Empty(printer.GetErrorLines()) + s.Equal(mockJob, printer.GetLines()[0].(*model.Job)) + }) + + s.Run("workers exceeds max", func() { + printer.Clean() + importFile := "import.zip" + tooMany := runtime.NumCPU()*4 + 1 + + cmd := &cobra.Command{} + cmd.Flags().Bool("bypass-upload", false, "") + cmd.Flags().Bool("extract-content", false, "") + cmd.Flags().Int("workers", 0, "") + _ = cmd.Flags().Set("workers", strconv.Itoa(tooMany)) + + err := importProcessCmdF(s.client, cmd, []string{importFile}) + s.Require().NotNil(err) + s.Contains(err.Error(), "exceeds maximum allowed") + s.Empty(printer.GetLines()) + s.Empty(printer.GetErrorLines()) + }) + + s.Run("custom workers", func() { + printer.Clean() + importFile := "import.zip" + mockJob := &model.Job{ + Type: model.JobTypeImportProcess, + Data: map[string]string{ + "import_file": importFile, + "local_mode": "false", + "extract_content": "false", + "workers": "2", + }, + } + + s.client. + EXPECT(). + CreateJob(context.TODO(), mockJob). + Return(mockJob, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("bypass-upload", false, "") + cmd.Flags().Bool("extract-content", false, "") + cmd.Flags().Int("workers", 0, "") + _ = cmd.Flags().Set("workers", "2") + + err := importProcessCmdF(s.client, cmd, []string{importFile}) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Empty(printer.GetErrorLines()) + s.Equal(mockJob, printer.GetLines()[0].(*model.Job)) + }) } func (s *MmctlUnitTestSuite) TestImportValidateCmdF() { diff --git a/webapp/channels/src/components/global_header/left_controls/product_menu/__snapshots__/product_menu.test.tsx.snap b/webapp/channels/src/components/global_header/left_controls/product_menu/__snapshots__/product_menu.test.tsx.snap index 60611824377..7c710d9cd85 100644 --- a/webapp/channels/src/components/global_header/left_controls/product_menu/__snapshots__/product_menu.test.tsx.snap +++ b/webapp/channels/src/components/global_header/left_controls/product_menu/__snapshots__/product_menu.test.tsx.snap @@ -46,7 +46,7 @@ exports[`components/global/product_switcher should have an active button state w role="menu" > @@ -86,7 +86,7 @@ exports[`components/global/product_switcher should have an active button state w Boards + Playbooks +
@@ -328,7 +362,7 @@ exports[`components/global/product_switcher should match snapshot with product s Boards
+ Playbooks +
@@ -531,7 +599,7 @@ exports[`components/global/product_switcher should render once when there are no Boards
+ Playbooks +
@@ -695,7 +797,7 @@ exports[`components/global/product_switcher should render the correct amount of Boards
+ Playbooks +
; describe('components/ProductMenuItem', () => { const defaultProps: ProductMenuItemProps = { @@ -75,14 +76,21 @@ describe('components/ProductMenuItem', () => { expect(svgElements.length).toBe(2); }); - test('should not show check icon when active is false', () => { + test('should show open in new tab button when active is false', () => { renderWithContext(); - const menuItem = screen.getByRole('menuitem'); + expect(screen.getByLabelText('Open in new tab')).toBeInTheDocument(); + }); - // When not active, there should only be one SVG element: the product icon - const svgElements = menuItem.querySelectorAll('svg'); - expect(svgElements.length).toBe(1); + test('should not show open in new tab button when active is true', () => { + const props: ProductMenuItemProps = { + ...defaultProps, + active: true, + }; + + renderWithContext(); + + expect(screen.queryByLabelText('Open in new tab')).not.toBeInTheDocument(); }); test('should call onClick when clicked', async () => { @@ -99,6 +107,24 @@ describe('components/ProductMenuItem', () => { expect(onClick).toHaveBeenCalledTimes(1); }); + test('should open destination in new tab when open in new tab button is clicked', async () => { + const onClick = jest.fn(); + const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(); + const props: ProductMenuItemProps = { + ...defaultProps, + onClick, + }; + + renderWithContext(); + + await userEvent.click(screen.getByLabelText('Open in new tab'), {pointerEventsCheck: 0}); + + expect(windowOpenSpy).toHaveBeenCalledWith('/test-destination', '_blank', 'noopener,noreferrer'); + expect(onClick).toHaveBeenCalledTimes(1); + + windowOpenSpy.mockRestore(); + }); + test('should render tour tip when provided', () => { const tourTipContent = 'Tour tip content'; const TourTip =
{tourTipContent}
; diff --git a/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.tsx b/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.tsx index c343a077d6e..c97cd34284c 100644 --- a/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.tsx +++ b/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.tsx @@ -1,13 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; import {Link} from 'react-router-dom'; import styled from 'styled-components'; -import glyphMap, {CheckIcon} from '@mattermost/compass-icons/components'; +import glyphMap, {CheckIcon, OpenInNewIcon} from '@mattermost/compass-icons/components'; import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs'; +import WithTooltip from 'components/with_tooltip'; + export interface ProductMenuItemProps { destination: string; icon: IconGlyphTypes | React.ReactNode; @@ -19,6 +22,34 @@ export interface ProductMenuItemProps { id?: string; } +const MenuItemTextContainer = styled.div` + margin-left: 8px; + flex-grow: 1; + font-weight: 600; + font-size: 14px; + line-height: 20px; +`; + +const OpenInNewTabButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + color: rgba(var(--center-channel-color-rgb), 0.56); + padding: 6px !important; + margin-right: -6px; + opacity: 0; + pointer-events: none; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.72); + } +`; + const MenuItem = styled(Link)` && { text-decoration: none; @@ -43,19 +74,24 @@ const MenuItem = styled(Link)` button { padding: 0 6px; } -`; -const MenuItemTextContainer = styled.div` - margin-left: 8px; - flex-grow: 1; - font-weight: 600; - font-size: 14px; - line-height: 20px; + &:hover ${OpenInNewTabButton} { + opacity: 1; + pointer-events: auto; + } `; const ProductMenuItem = ({icon, destination, text, active, onClick, tourTip, id}: ProductMenuItemProps): JSX.Element => { + const {formatMessage} = useIntl(); const ProductIcon = typeof icon === 'string' ? glyphMap[icon as IconGlyphTypes] : null; + const handleOpenInNewTab = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + window.open(destination, '_blank', 'noopener,noreferrer'); + onClick(); + }, [destination, onClick]); + return ( {text} - {active && ( + {active ? ( + ) : ( + + + + + )} {tourTip || null} diff --git a/webapp/channels/src/components/search_results/search_results.test.tsx b/webapp/channels/src/components/search_results/search_results.test.tsx index af6e9a55993..629c2e4dfc2 100644 --- a/webapp/channels/src/components/search_results/search_results.test.tsx +++ b/webapp/channels/src/components/search_results/search_results.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import SearchResults, {arePropsEqual} from 'components/search_results/search_results'; -import {renderWithContext, screen} from 'tests/react_testing_utils'; +import {renderWithContext} from 'tests/react_testing_utils'; import {getHistory} from 'utils/browser_history'; import {popoutRhsSearch} from 'utils/popouts/popout_windows'; import {TestHelper} from 'utils/test_helper'; @@ -168,18 +168,6 @@ describe('components/SearchResults', () => { }); }); - describe('handleChannelNameClick', () => { - test('should navigate to channel URL when channel display name is clicked', () => { - const pushMock = jest.fn(); - jest.mocked(getHistory).mockReturnValue({push: pushMock} as any); - - renderSearchResults({channelDisplayName: 'Test Channel'}); - screen.getByText('Test Channel').click(); - - expect(pushMock).toHaveBeenCalledWith(`/${team.name}/channels/${channel.name}`); - }); - }); - describe('arePropsEqual', () => { const result1 = {test: 'test'}; const result2 = {test: 'test'}; diff --git a/webapp/channels/src/components/search_results/search_results.tsx b/webapp/channels/src/components/search_results/search_results.tsx index 17f62bc6301..e702a7a0e13 100644 --- a/webapp/channels/src/components/search_results/search_results.tsx +++ b/webapp/channels/src/components/search_results/search_results.tsx @@ -28,7 +28,6 @@ import SearchHint from 'components/search_hint/search_hint'; import SearchResultsHeader from 'components/search_results_header'; import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; -import {getHistory} from 'utils/browser_history'; import {searchHintOptions, DataSearchTypes, RHSStates} from 'utils/constants'; import {isFileAttachmentsEnabled} from 'utils/file_utils'; import {popoutRhsSearch} from 'utils/popouts/popout_windows'; @@ -268,12 +267,6 @@ const SearchResults: React.FC = (props: Props): JSX.Element => { ); }, [isMentionSearch, isFlaggedPosts, isPinnedPosts, isChannelFiles, intl, searchTerms, searchType, currentTeam?.name, currentChannel?.name, searchTeamId]); - const handleChannelNameClick = useCallback(() => { - if (currentTeam?.name && currentChannel?.name) { - getHistory().push(`/${currentTeam.name}/channels/${currentChannel.name}`); - } - }, [currentTeam?.name, currentChannel?.name]); - switch (true) { case isLoading: contentItems = ( @@ -390,14 +383,7 @@ const SearchResults: React.FC = (props: Props): JSX.Element => {

{formattedTitle}

- {props.channelDisplayName && - - } + {props.channelDisplayName &&
{props.channelDisplayName}
} {isMessagesSearch &&