From 794307556f7c75e8948395358465fcd7fee823bd Mon Sep 17 00:00:00 2001 From: Mustafa Kara Date: Mon, 25 Jul 2022 17:01:29 +0300 Subject: [PATCH 01/22] Fix lint pipeline to fetch enterprise repo Signed-off-by: Mustafa Kara --- .github/workflows/lint-server.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/lint-server.yml b/.github/workflows/lint-server.yml index 4ffc48a12c7..aaf522ff6d9 100644 --- a/.github/workflows/lint-server.yml +++ b/.github/workflows/lint-server.yml @@ -7,6 +7,12 @@ on: branches: [ main, release-** ] workflow_dispatch: +<<<<<<< Updated upstream +======= +env: + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + +>>>>>>> Stashed changes jobs: golangci: name: plugin @@ -16,6 +22,24 @@ jobs: with: go-version: 1.18.1 - uses: actions/checkout@v3 +<<<<<<< Updated upstream +======= + with: + path: "focalboard" + - id: "mattermostServer" + uses: actions/checkout@v3 + continue-on-error: true + with: + repository: "mattermost/mattermost-server" + path: "mattermost-server" + ref: ${{ env.BRANCH_NAME }} + - uses: actions/checkout@v3 + if: steps.mattermostServer.outcome == 'failure' + with: + repository: "mattermost/mattermost-server" + path: "mattermost-server" + ref : "master" +>>>>>>> Stashed changes - name: set up golangci-lint run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.46.2 - name: lint From b3f28693f1ebfe8d7791dcad0c4c2e238cb9f7e2 Mon Sep 17 00:00:00 2001 From: Mustafa Kara Date: Mon, 25 Jul 2022 17:05:04 +0300 Subject: [PATCH 02/22] Update lint-server.yml --- .github/workflows/lint-server.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/lint-server.yml b/.github/workflows/lint-server.yml index aaf522ff6d9..f50a019ad46 100644 --- a/.github/workflows/lint-server.yml +++ b/.github/workflows/lint-server.yml @@ -7,12 +7,9 @@ on: branches: [ main, release-** ] workflow_dispatch: -<<<<<<< Updated upstream -======= env: BRANCH_NAME: ${{ github.head_ref || github.ref_name }} ->>>>>>> Stashed changes jobs: golangci: name: plugin @@ -22,8 +19,6 @@ jobs: with: go-version: 1.18.1 - uses: actions/checkout@v3 -<<<<<<< Updated upstream -======= with: path: "focalboard" - id: "mattermostServer" @@ -39,7 +34,6 @@ jobs: repository: "mattermost/mattermost-server" path: "mattermost-server" ref : "master" ->>>>>>> Stashed changes - name: set up golangci-lint run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.46.2 - name: lint From 4fa713d75b5048c80a69f4e62d34d593f44ee5d4 Mon Sep 17 00:00:00 2001 From: Mustafa Kara Date: Mon, 25 Jul 2022 17:08:02 +0300 Subject: [PATCH 03/22] Fix pipeline code Signed-off-by: Mustafa Kara --- .github/workflows/lint-server.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint-server.yml b/.github/workflows/lint-server.yml index f50a019ad46..89c304c5365 100644 --- a/.github/workflows/lint-server.yml +++ b/.github/workflows/lint-server.yml @@ -37,4 +37,6 @@ jobs: - name: set up golangci-lint run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.46.2 - name: lint - run: make server-lint + run: | + cd focalboard + make server-lint From ea37064e9441edae5901753f96e2fa50b69d296e Mon Sep 17 00:00:00 2001 From: Mustafa Kara Date: Tue, 26 Jul 2022 11:28:58 +0300 Subject: [PATCH 04/22] Update .github/workflows/lint-server.yml Co-authored-by: Akis Maziotis <36594156+phoinixgrr@users.noreply.github.com> --- .github/workflows/lint-server.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint-server.yml b/.github/workflows/lint-server.yml index 89c304c5365..35c5327a846 100644 --- a/.github/workflows/lint-server.yml +++ b/.github/workflows/lint-server.yml @@ -26,6 +26,7 @@ jobs: continue-on-error: true with: repository: "mattermost/mattermost-server" + fetch-depth: "20" path: "mattermost-server" ref: ${{ env.BRANCH_NAME }} - uses: actions/checkout@v3 From ba73ab082dac12b629278797f3c4c250c916043c Mon Sep 17 00:00:00 2001 From: Mustafa Kara Date: Tue, 26 Jul 2022 11:29:08 +0300 Subject: [PATCH 05/22] Update .github/workflows/lint-server.yml Co-authored-by: Akis Maziotis <36594156+phoinixgrr@users.noreply.github.com> --- .github/workflows/lint-server.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint-server.yml b/.github/workflows/lint-server.yml index 35c5327a846..ab55a3308e0 100644 --- a/.github/workflows/lint-server.yml +++ b/.github/workflows/lint-server.yml @@ -33,6 +33,7 @@ jobs: if: steps.mattermostServer.outcome == 'failure' with: repository: "mattermost/mattermost-server" + fetch-depth: "20" path: "mattermost-server" ref : "master" - name: set up golangci-lint From ac717490ca8a95f8d63c05998f72a1f9d12c1c26 Mon Sep 17 00:00:00 2001 From: Miguel de la Cruz Date: Wed, 27 Jul 2022 09:23:04 +0200 Subject: [PATCH 06/22] Replace query with exec on database update queries (#3420) --- .../store/sqlstore/data_migrations.go | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/server/services/store/sqlstore/data_migrations.go b/server/services/store/sqlstore/data_migrations.go index c4fb552ac19..ac41fce4365 100644 --- a/server/services/store/sqlstore/data_migrations.go +++ b/server/services/store/sqlstore/data_migrations.go @@ -199,8 +199,8 @@ func (s *SQLStore) getIDs(db sq.BaseRunner, table string) ([]string, error) { s.logger.Error("getIDs error", mlog.String("table", table), mlog.Err(err)) return nil, err } - defer s.CloseRows(rows) + var categoryIDs []string for rows.Next() { var id string @@ -218,40 +218,29 @@ func (s *SQLStore) getIDs(db sq.BaseRunner, table string) ([]string, error) { func (s *SQLStore) updateCategoryID(db sq.BaseRunner, oldID, newID string) error { // update in category table - rows, err := s.getQueryBuilder(db). + _, err := s.getQueryBuilder(db). Update(s.tablePrefix+"categories"). Set("id", newID). Where(sq.Eq{"id": oldID}). - Query() + Exec() if err != nil { s.logger.Error("updateCategoryID update category error", mlog.Err(err)) return err } - if err = rows.Close(); err != nil { - s.logger.Error("updateCategoryID error closing rows after updating categories table IDs", mlog.Err(err)) - return err - } - // update category boards table - - rows, err = s.getQueryBuilder(db). + _, err = s.getQueryBuilder(db). Update(s.tablePrefix+"category_boards"). Set("category_id", newID). Where(sq.Eq{"category_id": oldID}). - Query() + Exec() if err != nil { s.logger.Error("updateCategoryID update category boards error", mlog.Err(err)) return err } - if err := rows.Close(); err != nil { - s.logger.Error("updateCategoryID error closing rows after updating category boards table IDs", mlog.Err(err)) - return err - } - return nil } @@ -282,17 +271,16 @@ func (s *SQLStore) updateCategoryBlocksIDs(db sq.BaseRunner) error { func (s *SQLStore) updateCategoryBlocksID(db sq.BaseRunner, oldID, newID string) error { // update in category table - rows, err := s.getQueryBuilder(db). + _, err := s.getQueryBuilder(db). Update(s.tablePrefix+"category_boards"). Set("id", newID). Where(sq.Eq{"id": oldID}). - Query() + Exec() if err != nil { s.logger.Error("updateCategoryBlocksID update category error", mlog.Err(err)) return err } - rows.Close() return nil } From 6d5fa273b66f22d62cb2db23eedec72888384eb7 Mon Sep 17 00:00:00 2001 From: Mattermost Build Date: Wed, 27 Jul 2022 12:32:47 +0300 Subject: [PATCH 07/22] Openning the board in a new window from the RHS in channels (#3416) (#3421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 0eb3bbc0dcc669f9e75458e75912ba56a4a9d73e) Co-authored-by: Jesús Espino Co-authored-by: Mattermod --- .../webapp/src/components/rhsChannelBoardItem.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mattermost-plugin/webapp/src/components/rhsChannelBoardItem.tsx b/mattermost-plugin/webapp/src/components/rhsChannelBoardItem.tsx index ba1a58c70e1..542fe3a38d0 100644 --- a/mattermost-plugin/webapp/src/components/rhsChannelBoardItem.tsx +++ b/mattermost-plugin/webapp/src/components/rhsChannelBoardItem.tsx @@ -13,9 +13,12 @@ import OptionsIcon from '../../../../webapp/src/widgets/icons/options' import DeleteIcon from '../../../../webapp/src/widgets/icons/delete' import Menu from '../../../../webapp/src/widgets/menu' import MenuWrapper from '../../../../webapp/src/widgets/menuWrapper' +import {SuiteWindow} from '../../../../webapp/src/types/index' import './rhsChannelBoardItem.scss' +const windowAny = (window as SuiteWindow) + type Props = { board: Board } @@ -30,8 +33,7 @@ const RHSChannelBoardItem = (props: Props) => { } const handleBoardClicked = (boardID: string) => { - const windowAny: any = window - windowAny.WebappUtils.browserHistory.push(`/boards/team/${team.id}/${boardID}`) + window.open(`${windowAny.frontendBaseURL}/team/${team.id}/${boardID}`, '_blank', 'noopener') } const onUnlinkBoard = async (board: Board) => { From 3da7ca8e743bc3a3f9515c6053209892b2df07f9 Mon Sep 17 00:00:00 2001 From: Mattermost Build Date: Wed, 27 Jul 2022 13:26:17 +0300 Subject: [PATCH 08/22] Improve the board creation from channels (#3415) (#3433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improve the board creation from channels * Fixing linter problem and adding channelID to the telemetry information * Fixing and expanding a bit the tests Co-authored-by: Mattermod (cherry picked from commit 93bc9de731127d31c6d9a4c46f155fe8dab8f3fe) Co-authored-by: Jesús Espino --- .../webapp/src/components/boardSelector.tsx | 30 +++-------- mattermost-plugin/webapp/src/index.tsx | 3 +- .../boardTemplateSelector.test.tsx | 52 ++++++++++++++++++- .../boardTemplateSelector.tsx | 13 +++-- .../boardSwitcherDialog.tsx | 3 +- .../components/sidebar/sidebarCategory.tsx | 2 +- webapp/src/components/viewMenu.tsx | 2 +- webapp/src/components/workspace.tsx | 5 +- webapp/src/pages/boardPage/boardPage.tsx | 3 +- .../boardPage/teamToBoardAndViewRedirect.tsx | 5 +- webapp/src/router.tsx | 4 ++ webapp/src/types/index.d.ts | 1 + webapp/src/utils.ts | 9 +++- 13 files changed, 94 insertions(+), 38 deletions(-) diff --git a/mattermost-plugin/webapp/src/components/boardSelector.tsx b/mattermost-plugin/webapp/src/components/boardSelector.tsx index 877f235dc75..e4fba9dce94 100644 --- a/mattermost-plugin/webapp/src/components/boardSelector.tsx +++ b/mattermost-plugin/webapp/src/components/boardSelector.tsx @@ -12,8 +12,7 @@ import {useWebsockets} from '../../../../webapp/src/hooks/websockets' import octoClient from '../../../../webapp/src/octoClient' import mutator from '../../../../webapp/src/mutator' import {getCurrentTeamId, getAllTeams, Team} from '../../../../webapp/src/store/teams' -import {createBoard, BoardsAndBlocks, Board} from '../../../../webapp/src/blocks/board' -import {createBoardView} from '../../../../webapp/src/blocks/boardView' +import {createBoard, Board} from '../../../../webapp/src/blocks/board' import {useAppSelector, useAppDispatch} from '../../../../webapp/src/store/hooks' import {EmptySearch, EmptyResults} from '../../../../webapp/src/components/searchDialog/searchDialog' import ConfirmationDialog from '../../../../webapp/src/components/confirmationDialogBox' @@ -21,11 +20,13 @@ import Dialog from '../../../../webapp/src/components/dialog' import SearchIcon from '../../../../webapp/src/widgets/icons/search' import Button from '../../../../webapp/src/widgets/buttons/button' import {getCurrentLinkToChannel, setLinkToChannel} from '../../../../webapp/src/store/boards' -import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../../../webapp/src/telemetry/telemetryClient' import {WSClient} from '../../../../webapp/src/wsclient' +import {SuiteWindow} from '../../../../webapp/src/types/index' import BoardSelectorItem from './boardSelectorItem' +const windowAny = (window as SuiteWindow) + import './boardSelector.scss' const BoardSelector = () => { @@ -107,27 +108,8 @@ const BoardSelector = () => { } const newLinkedBoard = async (): Promise => { - const board = {...createBoard(), teamId, channelId: currentChannel} - - const view = createBoardView() - view.fields.viewType = 'board' - view.parentId = board.id - view.boardId = board.id - view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board view'}) - - await mutator.createBoardsAndBlocks( - {boards: [board], blocks: [view]}, - 'add linked board', - async (bab: BoardsAndBlocks): Promise => { - const windowAny: any = window - const newBoard = bab.boards[0] - // TODO: Maybe create a new event for create linked board - TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoard, {board: newBoard?.id}) - windowAny.WebappUtils.browserHistory.push(`/boards/team/${teamId}/${newBoard.id}`) - dispatch(setLinkToChannel('')) - }, - async () => {return}, - ) + window.open(`${windowAny.frontendBaseURL}/team/${teamId}/new/${currentChannel}`, '_blank', 'noopener') + dispatch(setLinkToChannel('')) } return ( diff --git a/mattermost-plugin/webapp/src/index.tsx b/mattermost-plugin/webapp/src/index.tsx index a0aed794b53..35d06650064 100644 --- a/mattermost-plugin/webapp/src/index.tsx +++ b/mattermost-plugin/webapp/src/index.tsx @@ -284,8 +284,9 @@ export default class Plugin { const goToFocalboardTemplate = () => { const currentTeam = mmStore.getState().entities.teams.currentTeamId + const currentChannel = mmStore.getState().entities.channels.currentChannelId TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ClickChannelIntro, {teamID: currentTeam}) - window.open(`${windowAny.frontendBaseURL}/team/${currentTeam}`, '_blank', 'noopener') + window.open(`${windowAny.frontendBaseURL}/team/${currentTeam}/new/${currentChannel}`, '_blank', 'noopener') } if (registry.registerChannelIntroButtonAction) { diff --git a/webapp/src/components/boardTemplateSelector/boardTemplateSelector.test.tsx b/webapp/src/components/boardTemplateSelector/boardTemplateSelector.test.tsx index f7106f20850..32864dcf8e4 100644 --- a/webapp/src/components/boardTemplateSelector/boardTemplateSelector.test.tsx +++ b/webapp/src/components/boardTemplateSelector/boardTemplateSelector.test.tsx @@ -15,6 +15,7 @@ import {MemoryRouter, Router} from 'react-router-dom' import Mutator from '../../mutator' import {Utils} from '../../utils' import {Team} from '../../store/teams' +import {createBoard, Board} from '../../blocks/board' import {IUser} from '../../user' import {mockDOM, mockStateStore, wrapDNDIntl} from '../../testUtils' @@ -227,17 +228,22 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => { userEvent.click(divNewTemplate!) expect(mockedMutator.addEmptyBoardTemplate).toBeCalledTimes(1) }) - test('return BoardTemplateSelector and click empty board', () => { + test('return BoardTemplateSelector and click empty board', async () => { + const newBoard = createBoard({id: 'new-board'} as Board) + mockedMutator.addEmptyBoard.mockResolvedValue({boards: [newBoard], blocks: []}) + render(wrapDNDIntl( , ), {wrapper: MemoryRouter}) + const divEmptyboard = screen.getByText('Create empty board').parentElement expect(divEmptyboard).not.toBeNull() userEvent.click(divEmptyboard!) expect(mockedMutator.addEmptyBoard).toBeCalledTimes(1) + await waitFor(() => expect(mockedMutator.updateBoard).toBeCalledWith(newBoard, newBoard, 'linked channel')) }) test('return BoardTemplateSelector and click delete template icon', async () => { const root = document.createElement('div') @@ -279,6 +285,9 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => { userEvent.click(editIcon!) }) test('return BoardTemplateSelector and click to add board from template', async () => { + const newBoard = createBoard({id: 'new-board'} as Board) + mockedMutator.addBoardFromTemplate.mockResolvedValue({boards: [newBoard], blocks: []}) + render(wrapDNDIntl( @@ -300,8 +309,44 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => { await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledTimes(1)) await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledWith(team1.id, expect.anything(), expect.anything(), expect.anything(), '1', team1.id)) + await waitFor(() => expect(mockedMutator.updateBoard).toBeCalledWith(newBoard, newBoard, 'linked channel')) }) + + test('return BoardTemplateSelector and click to add board from template with channelId', async () => { + const newBoard = createBoard({id: 'new-board'} as Board) + mockedMutator.addBoardFromTemplate.mockResolvedValue({boards: [newBoard], blocks: []}) + + render(wrapDNDIntl( + + + + , + ), {wrapper: MemoryRouter}) + const divBoardToSelect = screen.getByText(template1Title).parentElement + expect(divBoardToSelect).not.toBeNull() + + act(() => { + userEvent.click(divBoardToSelect!) + }) + + const useTemplateButton = screen.getByText('Use this template').parentElement + expect(useTemplateButton).not.toBeNull() + act(() => { + userEvent.click(useTemplateButton!) + }) + + await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledTimes(1)) + await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledWith(team1.id, expect.anything(), expect.anything(), expect.anything(), '1', team1.id)) + await waitFor(() => expect(mockedMutator.updateBoard).toBeCalledWith({...newBoard, channelId: 'test-channel'}, newBoard, 'linked channel')) + }) + test('return BoardTemplateSelector and click to add board from global template', async () => { + const newBoard = createBoard({id: 'new-board'} as Board) + mockedMutator.addBoardFromTemplate.mockResolvedValue({boards: [newBoard], blocks: []}) + render(wrapDNDIntl( @@ -323,8 +368,12 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => { await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledTimes(1)) await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledWith(team1.id, expect.anything(), expect.anything(), expect.anything(), 'global-1', team1.id)) await waitFor(() => expect(mockedTelemetry.trackEvent).toBeCalledWith('boards', 'createBoardViaTemplate', {boardTemplateId: 'template_id_global'})) + await waitFor(() => expect(mockedMutator.updateBoard).toBeCalledWith(newBoard, newBoard, 'linked channel')) }) test('should start product tour on choosing welcome template', async () => { + const newBoard = createBoard({id: 'new-board'} as Board) + mockedMutator.addBoardFromTemplate.mockResolvedValue({boards: [newBoard], blocks: []}) + render(wrapDNDIntl( @@ -347,6 +396,7 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => { await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledTimes(1)) await waitFor(() => expect(mockedMutator.addBoardFromTemplate).toBeCalledWith(team1.id, expect.anything(), expect.anything(), expect.anything(), '2', team1.id)) await waitFor(() => expect(mockedTelemetry.trackEvent).toBeCalledWith('boards', 'createBoardViaTemplate', {boardTemplateId: 'template_id_2'})) + await waitFor(() => expect(mockedMutator.updateBoard).toBeCalledWith(newBoard, newBoard, 'linked channel')) expect(mockedOctoClient.patchUserConfig).toBeCalledWith('user-id-1', { updatedFields: { 'focalboard_onboardingTourStarted': '1', diff --git a/webapp/src/components/boardTemplateSelector/boardTemplateSelector.tsx b/webapp/src/components/boardTemplateSelector/boardTemplateSelector.tsx index 6c2b2c98d8d..7aec414731f 100644 --- a/webapp/src/components/boardTemplateSelector/boardTemplateSelector.tsx +++ b/webapp/src/components/boardTemplateSelector/boardTemplateSelector.tsx @@ -34,6 +34,7 @@ type Props = { title?: React.ReactNode description?: React.ReactNode onClose?: () => void + channelId?: string } const BoardTemplateSelector = (props: Props) => { @@ -99,10 +100,12 @@ const BoardTemplateSelector = (props: Props) => { const handleUseTemplate = async () => { if (activeTemplate.teamId === '0') { - TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoardViaTemplate, {boardTemplateId: activeTemplate.properties.trackingTemplateId as string}) + TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoardViaTemplate, {boardTemplateId: activeTemplate.properties.trackingTemplateId as string, channelID: props.channelId}) } - await mutator.addBoardFromTemplate(currentTeam?.id || Constants.globalTeamId, intl, showBoard, () => showBoard(currentBoardId), activeTemplate.id, currentTeam?.id) + const boardsAndBlocks = await mutator.addBoardFromTemplate(currentTeam?.id || Constants.globalTeamId, intl, showBoard, () => showBoard(currentBoardId), activeTemplate.id, currentTeam?.id) + const board = boardsAndBlocks.boards[0] + await mutator.updateBoard({...board, channelId: props.channelId || ''}, board, 'linked channel') if (activeTemplate.title === OnboardingBoardTitle) { resetTour() } @@ -193,7 +196,11 @@ const BoardTemplateSelector = (props: Props) => { filled={false} emphasis={'secondary'} size={'medium'} - onClick={() => mutator.addEmptyBoard(currentTeam?.id || '', intl, showBoard, () => showBoard(currentBoardId))} + onClick={async () => { + const boardsAndBlocks = await mutator.addEmptyBoard(currentTeam?.id || '', intl, showBoard, () => showBoard(currentBoardId)) + const board = boardsAndBlocks.boards[0] + await mutator.updateBoard({...board, channelId: props.channelId || ''}, board, 'linked channel') + }} > { if (!me) { return } - const newPath = generatePath(match.path, {...match.params, teamId, boardId, viewId: undefined}) + const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, teamId, boardId, viewId: undefined}) history.push(newPath) props.onClose() } diff --git a/webapp/src/components/sidebar/sidebarCategory.tsx b/webapp/src/components/sidebar/sidebarCategory.tsx index 51d8cde67ec..bebf61bdab3 100644 --- a/webapp/src/components/sidebar/sidebarCategory.tsx +++ b/webapp/src/components/sidebar/sidebarCategory.tsx @@ -71,7 +71,7 @@ const SidebarCategory = (props: Props) => { if (boardId !== match.params.boardId && viewId !== match.params.viewId) { params.cardId = undefined } - const newPath = generatePath(match.path, params) + const newPath = generatePath(Utils.getBoardPagePath(match.path), params) history.push(newPath) props.hideSidebar() }, [match, history]) diff --git a/webapp/src/components/viewMenu.tsx b/webapp/src/components/viewMenu.tsx index 083cce990b9..f068f046e11 100644 --- a/webapp/src/components/viewMenu.tsx +++ b/webapp/src/components/viewMenu.tsx @@ -37,7 +37,7 @@ const ViewMenu = (props: Props) => { const match = useRouteMatch() const showView = useCallback((viewId) => { - let newPath = generatePath(match.path, {...match.params, viewId: viewId || ''}) + let newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, viewId: viewId || ''}) if (props.readonly) { newPath += `?r=${Utils.getReadToken()}` } diff --git a/webapp/src/components/workspace.tsx b/webapp/src/components/workspace.tsx index f5f6ea1f7bb..5ba1ac33933 100644 --- a/webapp/src/components/workspace.tsx +++ b/webapp/src/components/workspace.tsx @@ -35,7 +35,7 @@ type Props = { function CenterContent(props: Props) { const team = useAppSelector(getCurrentTeam) const isLoading = useAppSelector(isLoadingBoard) - const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string}>() + const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string, channelId?: string}>() const board = useAppSelector(getCurrentBoard) const templates = useAppSelector(getTemplates) const cards = useAppSelector(getCurrentViewCardsSortedFilteredAndGrouped) @@ -51,7 +51,7 @@ function CenterContent(props: Props) { const showCard = useCallback((cardId?: string) => { const params = {...match.params, cardId} - let newPath = generatePath(match.path, params) + let newPath = generatePath(Utils.getBoardPagePath(match.path), params) if (props.readonly) { newPath += `?r=${Utils.getReadToken()}` } @@ -129,6 +129,7 @@ function CenterContent(props: Props) { }} /> } + channelId={match.params.channelId} /> ) } diff --git a/webapp/src/pages/boardPage/boardPage.tsx b/webapp/src/pages/boardPage/boardPage.tsx index 90b57a19392..c91e1361fed 100644 --- a/webapp/src/pages/boardPage/boardPage.tsx +++ b/webapp/src/pages/boardPage/boardPage.tsx @@ -58,6 +58,7 @@ import './boardPage.scss' type Props = { readonly?: boolean + new?: boolean } const BoardPage = (props: Props): JSX.Element => { @@ -202,7 +203,7 @@ const BoardPage = (props: Props): JSX.Element => { return (
- + {!props.new && } diff --git a/webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx b/webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx index 580d9edd644..219cfaecb17 100644 --- a/webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx +++ b/webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx @@ -7,6 +7,7 @@ import {getBoards, getCurrentBoardId} from '../../store/boards' import {setCurrent as setCurrentView, getCurrentBoardViews} from '../../store/views' import {useAppSelector, useAppDispatch} from '../../store/hooks' import {UserSettings} from '../../userSettings' +import {Utils} from '../../utils' import {getSidebarCategories} from '../../store/sidebar' import {Constants} from "../../constants" @@ -49,7 +50,7 @@ const TeamToBoardAndViewRedirect = (): null => { } if (boardID) { - const newPath = generatePath(match.path, {...match.params, boardId: boardID, viewID: undefined}) + const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, boardId: boardID, viewID: undefined}) history.replace(newPath) // return from here because the loadBoardData() call @@ -77,7 +78,7 @@ const TeamToBoardAndViewRedirect = (): null => { } if (viewID) { - const newPath = generatePath(match.path, {...match.params, viewId: viewID}) + const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, viewId: viewID}) history.replace(newPath) } } diff --git a/webapp/src/router.tsx b/webapp/src/router.tsx index 6d0bebbf645..01b67f1ee7e 100644 --- a/webapp/src/router.tsx +++ b/webapp/src/router.tsx @@ -165,6 +165,10 @@ const FocalboardRouter = (props: Props): JSX.Element => { } + + + + diff --git a/webapp/src/types/index.d.ts b/webapp/src/types/index.d.ts index 7abe97a64a5..60b84d0dde1 100644 --- a/webapp/src/types/index.d.ts +++ b/webapp/src/types/index.d.ts @@ -21,4 +21,5 @@ export type SuiteWindow = Window & { baseURL?: string frontendBaseURL?: string isFocalboardPlugin?: boolean + WebappUtils?: any } diff --git a/webapp/src/utils.ts b/webapp/src/utils.ts index d03349caee0..5fe5a6045a8 100644 --- a/webapp/src/utils.ts +++ b/webapp/src/utils.ts @@ -757,6 +757,13 @@ class Utils { return (Utils.isMac() && e.metaKey) || (!Utils.isMac() && e.ctrlKey && !e.altKey) } + static getBoardPagePath(currentPath: string) { + if (currentPath == "/team/:teamId/new/:channelId") { + return "/team/:teamId/:boardId?/:viewId?/:cardId?" + } + return currentPath + } + static showBoard( boardId: string, match: routerMatch<{boardId: string, viewId?: string, cardId?: string, teamId?: string}>, @@ -769,7 +776,7 @@ class Utils { params.viewId = undefined params.cardId = undefined } - const newPath = generatePath(match.path, params) + const newPath = generatePath(Utils.getBoardPagePath(match.path), params) history.push(newPath) } From 95b65dba228b72be005eff27956a57fbf15b643c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Wed, 27 Jul 2022 18:50:25 +0200 Subject: [PATCH 09/22] Not requesting for channels in personal server mode (#3429) --- .../src/components/shareBoard/shareBoard.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/webapp/src/components/shareBoard/shareBoard.tsx b/webapp/src/components/shareBoard/shareBoard.tsx index 8c5124e76cf..b2c4ee99e74 100644 --- a/webapp/src/components/shareBoard/shareBoard.tsx +++ b/webapp/src/components/shareBoard/shareBoard.tsx @@ -348,14 +348,19 @@ export default function ShareBoardDialog(props: Props): JSX.Element { className={'userSearchInput'} cacheOptions={true} loadOptions={async (inputValue: string) => { - const users = await client.searchTeamUsers(inputValue) - const channels = await client.searchUserChannels(match.params.teamId || '', inputValue) const result = [] - if (users) { - result.push({label: intl.formatMessage({id: 'shareBoard.members-select-group', defaultMessage: 'Members'}), options: users || []}) - } - if (channels) { - result.push({label: intl.formatMessage({id: 'shareBoard.channels-select-group', defaultMessage: 'Channels'}), options: channels || []}) + if (Utils.isFocalboardPlugin()) { + const users = await client.searchTeamUsers(inputValue) + if (users) { + result.push({label: intl.formatMessage({id: 'shareBoard.members-select-group', defaultMessage: 'Members'}), options: users || []}) + } + const channels = await client.searchUserChannels(match.params.teamId || '', inputValue) + if (channels) { + result.push({label: intl.formatMessage({id: 'shareBoard.channels-select-group', defaultMessage: 'Channels'}), options: channels || []}) + } + } else { + const users = await client.searchTeamUsers(inputValue) || [] + result.push(...users) } return result }} From 36a073eaad764dc30a156cb04c7f83b8974a4bf4 Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Wed, 27 Jul 2022 23:47:38 +0530 Subject: [PATCH 10/22] Handled err in acse of lack of user-channel membership (#3378) * Handled err in acse of lack of user-channel membership * Used errors.As instead of type check Co-authored-by: Mattermod --- .../store/mattermostauthlayer/mattermostauthlayer.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/services/store/mattermostauthlayer/mattermostauthlayer.go b/server/services/store/mattermostauthlayer/mattermostauthlayer.go index 05996b60a69..253eb278d15 100644 --- a/server/services/store/mattermostauthlayer/mattermostauthlayer.go +++ b/server/services/store/mattermostauthlayer/mattermostauthlayer.go @@ -669,8 +669,16 @@ func (s *MattermostAuthLayer) GetMemberForBoard(boardID, userID string) (*model. if b.ChannelID != "" { _, err := s.servicesAPI.GetChannelMember(b.ChannelID, userID) if err != nil { + var appErr *mmModel.AppError + if errors.As(err, &appErr) && appErr.StatusCode == http.StatusNotFound { + // Plugin API returns error if channel member doesn't exist. + // We're fine if it doesn't exist, so its not an error for us. + return nil, nil + } + return nil, err } + return &model.BoardMember{ BoardID: boardID, UserID: userID, From e9e4b5851a6039e26f01deaa8f7e05e81f353bde Mon Sep 17 00:00:00 2001 From: Rajat Dabade Date: Thu, 28 Jul 2022 00:22:17 +0530 Subject: [PATCH 11/22] Bot ownerID fix (#3436) * Added the OwnerId for creation of the bot --- mattermost-plugin/server/boards/notifications.go | 1 + 1 file changed, 1 insertion(+) diff --git a/mattermost-plugin/server/boards/notifications.go b/mattermost-plugin/server/boards/notifications.go index 1e093f8d856..62ab31735e4 100644 --- a/mattermost-plugin/server/boards/notifications.go +++ b/mattermost-plugin/server/boards/notifications.go @@ -75,6 +75,7 @@ func createDelivery(servicesAPI model.ServicesAPI, serverRoot string) (*pluginde Username: botUsername, DisplayName: botDisplayname, Description: botDescription, + OwnerId: model.SystemUserID, } botID, err := servicesAPI.EnsureBot(bot) if err != nil { From a129dbc1b88ff9d873aa88893c0184ddc9dba5b3 Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Wed, 27 Jul 2022 13:35:36 -0600 Subject: [PATCH 12/22] Store current input while user is changing. (#3256) * add ref to retrieve current text setting * change to handle without forwardRefs * state not needed here * cleanup Co-authored-by: Mattermod --- webapp/src/widgets/menu/textInputOption.tsx | 10 +++++++--- webapp/src/widgets/propertyMenu.test.tsx | 21 +++++++++++++++++++++ webapp/src/widgets/propertyMenu.tsx | 9 +++++++-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/webapp/src/widgets/menu/textInputOption.tsx b/webapp/src/widgets/menu/textInputOption.tsx index cd5d0cf6ee3..0f96284f4c5 100644 --- a/webapp/src/widgets/menu/textInputOption.tsx +++ b/webapp/src/widgets/menu/textInputOption.tsx @@ -4,6 +4,7 @@ import React, {useState, useRef, useEffect} from 'react' type TextInputOptionProps = { initialValue: string, + onConfirmValue: (value: string) => void onValueChanged: (value: string) => void } @@ -22,13 +23,16 @@ function TextInputOption(props: TextInputOptionProps): JSX.Element { type='text' className='PropertyMenu menu-textbox menu-option' onClick={(e) => e.stopPropagation()} - onChange={(e) => setValue(e.target.value)} + onChange={(e) => { + setValue(e.target.value) + props.onValueChanged(value) + }} value={value} title={value} - onBlur={() => props.onValueChanged(value)} + onBlur={() => props.onConfirmValue(value)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === 'Escape') { - props.onValueChanged(value) + props.onConfirmValue(value) e.stopPropagation() if (e.key === 'Enter') { e.target.dispatchEvent(new Event('menuItemClicked')) diff --git a/webapp/src/widgets/propertyMenu.test.tsx b/webapp/src/widgets/propertyMenu.test.tsx index 9ba812a0713..fed6acb4e3d 100644 --- a/webapp/src/widgets/propertyMenu.test.tsx +++ b/webapp/src/widgets/propertyMenu.test.tsx @@ -83,6 +83,27 @@ describe('widgets/PropertyMenu', () => { setTimeout(() => expect(callback).toHaveBeenCalledWith('select', 'test-property'), 2000) }) + test('handles name and type change event', () => { + const callback = jest.fn() + const component = wrapIntl( + , + ) + const {getByDisplayValue, getByText} = render(component) + const input = getByDisplayValue(/test-property/i) + fireEvent.change(input, {target: {value: 'changed name'}}) + + const menuOpen = getByText(/Type: Text/i) + fireEvent.click(menuOpen) + fireEvent.click(getByText('Select')) + setTimeout(() => expect(callback).toHaveBeenCalledWith('select', 'changed name'), 2000) + }) + test('should match snapshot', () => { const callback = jest.fn() const component = wrapIntl( diff --git a/webapp/src/widgets/propertyMenu.tsx b/webapp/src/widgets/propertyMenu.tsx index dca3adcc15e..d2af19e63ef 100644 --- a/webapp/src/widgets/propertyMenu.tsx +++ b/webapp/src/widgets/propertyMenu.tsx @@ -91,6 +91,7 @@ export const PropertyTypes = (props: TypesProps): JSX.Element => { const PropertyMenu = (props: Props) => { const intl = useIntl() + let currentPropertyName = props.propertyName const deleteText = intl.formatMessage({ id: 'PropertyMenu.Delete', @@ -101,7 +102,11 @@ const PropertyMenu = (props: Props) => { props.onTypeAndNameChanged(props.propertyType, n)} + onConfirmValue={(n) => { + props.onTypeAndNameChanged(props.propertyType, n) + currentPropertyName = n + }} + onValueChanged={(n) => currentPropertyName = n} /> { > props.onTypeAndNameChanged(type, props.propertyName)} + onTypeSelected={(type: PropertyType) => props.onTypeAndNameChanged(type, currentPropertyName)} /> Date: Wed, 27 Jul 2022 23:21:19 +0200 Subject: [PATCH 13/22] Move category creation and UUID update to a more performant data migration (#3437) * Move category creation and UUID update to a more performant data migration * Fix linter * Fix linter --- .../store/sqlstore/data_migrations.go | 251 +++++++++++------- .../000019_populate_categories.up.sql | 33 --- .../000020_populate_category_blocks.up.sql | 24 +- 3 files changed, 156 insertions(+), 152 deletions(-) diff --git a/server/services/store/sqlstore/data_migrations.go b/server/services/store/sqlstore/data_migrations.go index ac41fce4365..23891f27e65 100644 --- a/server/services/store/sqlstore/data_migrations.go +++ b/server/services/store/sqlstore/data_migrations.go @@ -14,6 +14,11 @@ import ( ) const ( + // we group the inserts on batches of 1000 because PostgreSQL + // supports a limit of around 64K values (not rows) on an insert + // query, so we want to stay safely below. + CategoryInsertBatch = 1000 + TemplatesToTeamsMigrationKey = "TemplatesToTeamsMigrationComplete" UniqueIDsMigrationKey = "UniqueIDsMigrationComplete" CategoryUUIDIDMigrationKey = "CategoryUuidIdMigrationComplete" @@ -122,6 +127,10 @@ func (s *SQLStore) runUniqueIDsMigration() error { return nil } +// runCategoryUUIDIDMigration takes care of deriving the categories +// from the boards and its memberships. The name references UUID +// because of the preexisting purpose of this migration, and has been +// preserved for compatibility with already migrated instances. func (s *SQLStore) runCategoryUUIDIDMigration() error { setting, err := s.GetSystemSetting(CategoryUUIDIDMigrationKey) if err != nil { @@ -140,146 +149,196 @@ func (s *SQLStore) runCategoryUUIDIDMigration() error { return txErr } - if err := s.updateCategoryIDs(tx); err != nil { - return err - } + if s.isPlugin { + if err := s.createCategories(tx); err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + s.logger.Error("category UUIDs insert categories transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting")) + } + return err + } - if err := s.updateCategoryBlocksIDs(tx); err != nil { - return err + if err := s.createCategoryBoards(tx); err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + s.logger.Error("category UUIDs insert category boards transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting")) + } + return err + } } if err := s.setSystemSetting(tx, CategoryUUIDIDMigrationKey, strconv.FormatBool(true)); err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { - s.logger.Error("category IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting")) + s.logger.Error("category UUIDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting")) } return fmt.Errorf("cannot mark migration as completed: %w", err) } if err := tx.Commit(); err != nil { - return fmt.Errorf("cannot commit category IDs transaction: %w", err) + return fmt.Errorf("cannot commit category UUIDs transaction: %w", err) } - s.logger.Debug("category IDs migration finished successfully") + s.logger.Debug("category UUIDs migration finished successfully") return nil } -func (s *SQLStore) updateCategoryIDs(db sq.BaseRunner) error { - // fetch all category IDs - oldCategoryIDs, err := s.getIDs(db, "categories") - if err != nil { - return err - } - - // map old category ID to new ID - categoryIDs := map[string]string{} - for _, oldID := range oldCategoryIDs { - newID := utils.NewID(utils.IDTypeNone) - categoryIDs[oldID] = newID - } - - // update for each category ID. - // Update the new ID in category table, - // and update corresponding rows in category boards table. - for oldID, newID := range categoryIDs { - if err := s.updateCategoryID(db, oldID, newID); err != nil { - return err - } - } - - return nil -} - -func (s *SQLStore) getIDs(db sq.BaseRunner, table string) ([]string, error) { +func (s *SQLStore) createCategories(db sq.BaseRunner) error { rows, err := s.getQueryBuilder(db). - Select("id"). - From(s.tablePrefix + table). + Select("c.DisplayName, cm.UserId, c.TeamId, cm.ChannelId"). + From(s.tablePrefix + "boards boards"). + Join("ChannelMembers cm on boards.channel_id = cm.ChannelId"). + Join("Channels c on cm.ChannelId = c.id and (c.Type = 'O' or c.Type = 'P')"). + GroupBy("cm.UserId, c.TeamId, cm.ChannelId, c.DisplayName"). Query() if err != nil { - s.logger.Error("getIDs error", mlog.String("table", table), mlog.Err(err)) - return nil, err + s.logger.Error("get boards data error", mlog.Err(err)) + return err } defer s.CloseRows(rows) - var categoryIDs []string + initQuery := func() sq.InsertBuilder { + return s.getQueryBuilder(db). + Insert(s.tablePrefix+"categories"). + Columns( + "id", + "name", + "user_id", + "team_id", + "channel_id", + "create_at", + "update_at", + "delete_at", + ) + } + // query will accumulate the insert values until the limit is + // reached, and then it will be stored and reset + query := initQuery() + // queryList stores those queries that already reached the limit + // to be run when all the data is processed + queryList := []sq.InsertBuilder{} + counter := 0 + now := model.GetMillis() + for rows.Next() { - var id string - err := rows.Scan(&id) + var displayName string + var userID string + var teamID string + var channelID string + + err := rows.Scan( + &displayName, + &userID, + &teamID, + &channelID, + ) if err != nil { - s.logger.Error("getIDs scan row error", mlog.String("table", table), mlog.Err(err)) - return nil, err + return fmt.Errorf("cannot scan result while trying to create categories: %w", err) } - categoryIDs = append(categoryIDs, id) + query = query.Values( + utils.NewID(utils.IDTypeNone), + displayName, + userID, + teamID, + channelID, + now, + 0, + 0, + ) + + counter++ + if counter%CategoryInsertBatch == 0 { + queryList = append(queryList, query) + query = initQuery() + } } - return categoryIDs, nil -} - -func (s *SQLStore) updateCategoryID(db sq.BaseRunner, oldID, newID string) error { - // update in category table - _, err := s.getQueryBuilder(db). - Update(s.tablePrefix+"categories"). - Set("id", newID). - Where(sq.Eq{"id": oldID}). - Exec() - - if err != nil { - s.logger.Error("updateCategoryID update category error", mlog.Err(err)) - return err + if counter%CategoryInsertBatch != 0 { + queryList = append(queryList, query) } - // update category boards table - _, err = s.getQueryBuilder(db). - Update(s.tablePrefix+"category_boards"). - Set("category_id", newID). - Where(sq.Eq{"category_id": oldID}). - Exec() - - if err != nil { - s.logger.Error("updateCategoryID update category boards error", mlog.Err(err)) - return err + for _, q := range queryList { + if _, err := q.Exec(); err != nil { + return fmt.Errorf("cannot create category values: %w", err) + } } return nil } -func (s *SQLStore) updateCategoryBlocksIDs(db sq.BaseRunner) error { - // fetch all category IDs - oldCategoryIDs, err := s.getIDs(db, "category_boards") +func (s *SQLStore) createCategoryBoards(db sq.BaseRunner) error { + rows, err := s.getQueryBuilder(db). + Select("categories.user_id, categories.id, boards.id"). + From(s.tablePrefix + "categories categories"). + Join(s.tablePrefix + "boards boards on categories.channel_id = boards.channel_id AND boards.is_template = false"). + Query() + if err != nil { + s.logger.Error("get categories data error", mlog.Err(err)) return err } + defer s.CloseRows(rows) - // map old category ID to new ID - categoryIDs := map[string]string{} - for _, oldID := range oldCategoryIDs { - newID := utils.NewID(utils.IDTypeNone) - categoryIDs[oldID] = newID - } + initQuery := func() sq.InsertBuilder { + return s.getQueryBuilder(db). + Insert(s.tablePrefix+"category_boards"). + Columns( + "id", + "user_id", + "category_id", + "board_id", + "create_at", + "update_at", + "delete_at", + ) + } + // query will accumulate the insert values until the limit is + // reached, and then it will be stored and reset + query := initQuery() + // queryList stores those queries that already reached the limit + // to be run when all the data is processed + queryList := []sq.InsertBuilder{} + counter := 0 + now := model.GetMillis() - // update for each category ID. - // Update the new ID in category table, - // and update corresponding rows in category boards table. - for oldID, newID := range categoryIDs { - if err := s.updateCategoryBlocksID(db, oldID, newID); err != nil { - return err + for rows.Next() { + var userID string + var categoryID string + var boardID string + + err := rows.Scan( + &userID, + &categoryID, + &boardID, + ) + if err != nil { + return fmt.Errorf("cannot scan result while trying to create category boards: %w", err) + } + + query = query.Values( + utils.NewID(utils.IDTypeNone), + userID, + categoryID, + boardID, + now, + 0, + 0, + ) + + counter++ + if counter%CategoryInsertBatch == 0 { + queryList = append(queryList, query) + query = initQuery() } } - return nil -} -func (s *SQLStore) updateCategoryBlocksID(db sq.BaseRunner, oldID, newID string) error { - // update in category table - _, err := s.getQueryBuilder(db). - Update(s.tablePrefix+"category_boards"). - Set("id", newID). - Where(sq.Eq{"id": oldID}). - Exec() + if counter%CategoryInsertBatch != 0 { + queryList = append(queryList, query) + } - if err != nil { - s.logger.Error("updateCategoryBlocksID update category error", mlog.Err(err)) - return err + for _, q := range queryList { + if _, err := q.Exec(); err != nil { + return fmt.Errorf("cannot create category boards values: %w", err) + } } return nil diff --git a/server/services/store/sqlstore/migrations/000019_populate_categories.up.sql b/server/services/store/sqlstore/migrations/000019_populate_categories.up.sql index 695402d608f..d551eeb394a 100644 --- a/server/services/store/sqlstore/migrations/000019_populate_categories.up.sql +++ b/server/services/store/sqlstore/migrations/000019_populate_categories.up.sql @@ -11,36 +11,3 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}categories ( ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; CREATE INDEX idx_categories_user_id_team_id ON {{.prefix}}categories(user_id, team_id); - -{{if .plugin}} - INSERT INTO {{.prefix}}categories( - id, - name, - user_id, - team_id, - channel_id, - create_at, - update_at, - delete_at - ) - SELECT - {{ if .postgres }} - REPLACE(uuid_in(md5(random()::text || clock_timestamp()::text)::cstring)::varchar, '-', ''), - {{ end }} - {{ if .mysql }} - UUID(), - {{ end }} - c.DisplayName, - cm.UserId, - c.TeamId, - cm.ChannelId, - {{if .postgres}}(extract(epoch from now())*1000)::bigint,{{end}} - {{if .mysql}}UNIX_TIMESTAMP() * 1000,{{end}} - 0, - 0 - FROM - {{.prefix}}boards boards - JOIN ChannelMembers cm on boards.channel_id = cm.ChannelId - JOIN Channels c on cm.ChannelId = c.id and (c.Type = 'O' or c.Type = 'P') - GROUP BY cm.UserId, c.TeamId, cm.ChannelId, c.DisplayName; -{{end}} diff --git a/server/services/store/sqlstore/migrations/000020_populate_category_blocks.up.sql b/server/services/store/sqlstore/migrations/000020_populate_category_blocks.up.sql index 2846c53eb69..c5dd300c70b 100644 --- a/server/services/store/sqlstore/migrations/000020_populate_category_blocks.up.sql +++ b/server/services/store/sqlstore/migrations/000020_populate_category_blocks.up.sql @@ -7,28 +7,6 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}category_boards ( update_at BIGINT, delete_at BIGINT, PRIMARY KEY (id) - ) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; +) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}}; CREATE INDEX idx_categoryboards_category_id ON {{.prefix}}category_boards(category_id); - -{{if .plugin}} - INSERT INTO {{.prefix}}category_boards(id, user_id, category_id, board_id, create_at, update_at, delete_at) - SELECT - {{ if .postgres }} - REPLACE(uuid_in(md5(random()::text || clock_timestamp()::text)::cstring)::varchar, '-', ''), - {{ end }} - {{ if .mysql }} - UUID(), - {{ end }} - {{.prefix}}categories.user_id, - {{.prefix}}categories.id, - {{.prefix}}boards.id, - {{if .postgres}}(extract(epoch from now())*1000)::bigint,{{end}} - {{if .mysql}}UNIX_TIMESTAMP() * 1000,{{end}} - 0, - 0 - FROM - {{.prefix}}categories - JOIN {{.prefix}}boards ON {{.prefix}}categories.channel_id = {{.prefix}}boards.channel_id - AND {{.prefix}}boards.is_template = false; -{{end}} From dc59929206a2fc24e931e25fe16856759db96372 Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Thu, 28 Jul 2022 03:04:52 +0530 Subject: [PATCH 14/22] Converted synthetic membership to natuaral for board's channel members (#3390) * Converted synthetic membership to natuaral if needed * Added unit tests for AddMemberToBoard * Update server/app/boards_test.go Co-authored-by: Miguel de la Cruz * Fix test Co-authored-by: Miguel de la Cruz Co-authored-by: Mattermod --- server/app/boards.go | 2 +- server/app/boards_test.go | 107 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 server/app/boards_test.go diff --git a/server/app/boards.go b/server/app/boards.go index 1e6b34fe043..a66686b1f27 100644 --- a/server/app/boards.go +++ b/server/app/boards.go @@ -326,7 +326,7 @@ func (a *App) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, e return nil, err } - if existingMembership != nil { + if existingMembership != nil && !existingMembership.Synthetic { return existingMembership, nil } diff --git a/server/app/boards_test.go b/server/app/boards_test.go new file mode 100644 index 00000000000..0b70dda1be9 --- /dev/null +++ b/server/app/boards_test.go @@ -0,0 +1,107 @@ +package app + +import ( + "testing" + + "github.com/mattermost/focalboard/server/model" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestAddMemberToBoard(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + t.Run("base case", func(t *testing.T) { + const boardID = "board_id_1" + const userID = "user_id_1" + + boardMember := &model.BoardMember{ + BoardID: boardID, + UserID: userID, + SchemeEditor: true, + } + + th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ + TeamID: "team_id_1", + }, nil) + + th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(nil, nil) + + th.Store.EXPECT().SaveMember(mock.MatchedBy(func(i interface{}) bool { + p := i.(*model.BoardMember) + return p.BoardID == boardID && p.UserID == userID + })).Return(&model.BoardMember{ + BoardID: boardID, + }, nil) + + // for WS change broadcast + th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil) + + addedBoardMember, err := th.App.AddMemberToBoard(boardMember) + require.NoError(t, err) + require.Equal(t, boardID, addedBoardMember.BoardID) + }) + + t.Run("return existing non-synthetic membership if any", func(t *testing.T) { + const boardID = "board_id_1" + const userID = "user_id_1" + + boardMember := &model.BoardMember{ + BoardID: boardID, + UserID: userID, + SchemeEditor: true, + } + + th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ + TeamID: "team_id_1", + }, nil) + + th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(&model.BoardMember{ + UserID: userID, + BoardID: boardID, + Synthetic: false, + }, nil) + + addedBoardMember, err := th.App.AddMemberToBoard(boardMember) + require.NoError(t, err) + require.Equal(t, boardID, addedBoardMember.BoardID) + }) + + t.Run("should convert synthetic membership into natural membership", func(t *testing.T) { + const boardID = "board_id_1" + const userID = "user_id_1" + + boardMember := &model.BoardMember{ + BoardID: boardID, + UserID: userID, + SchemeEditor: true, + } + + th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{ + TeamID: "team_id_1", + }, nil) + + th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(&model.BoardMember{ + UserID: userID, + BoardID: boardID, + Synthetic: true, + }, nil) + + th.Store.EXPECT().SaveMember(mock.MatchedBy(func(i interface{}) bool { + p := i.(*model.BoardMember) + return p.BoardID == boardID && p.UserID == userID + })).Return(&model.BoardMember{ + UserID: userID, + BoardID: boardID, + Synthetic: false, + }, nil) + + // for WS change broadcast + th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil) + + addedBoardMember, err := th.App.AddMemberToBoard(boardMember) + require.NoError(t, err) + require.Equal(t, boardID, addedBoardMember.BoardID) + }) +} From bf1ad30b7503f3fc019188998d3e49eb2ce6b7e9 Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Thu, 28 Jul 2022 07:35:21 +0530 Subject: [PATCH 15/22] Added min height for template sepector (#3427) --- .../components/boardTemplateSelector/boardTemplateSelector.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/src/components/boardTemplateSelector/boardTemplateSelector.scss b/webapp/src/components/boardTemplateSelector/boardTemplateSelector.scss index 1a41e904d9e..312f2a4d0a2 100644 --- a/webapp/src/components/boardTemplateSelector/boardTemplateSelector.scss +++ b/webapp/src/components/boardTemplateSelector/boardTemplateSelector.scss @@ -81,6 +81,7 @@ overflow: hidden; width: 100%; max-width: 1000px; + height: 512px; .buttons { display: flex; From 67608a833b5d3e004e3a424b90d922d749840258 Mon Sep 17 00:00:00 2001 From: Ibrahim Serdar Acikgoz Date: Thu, 28 Jul 2022 07:33:55 +0300 Subject: [PATCH 16/22] Update product adapter for changes in the UserService (#3401) * reflect changes in server regarding to user service * include server into build even in CI * Fix lint pipeline to use only local repositories Signed-off-by: Mustafa Kara Co-authored-by: Mattermod Co-authored-by: Mustafa Kara --- .github/workflows/lint-server.yml | 1 + mattermost-plugin/build/gowork/main.go | 14 +++++++++++--- mattermost-plugin/product/api_adapter.go | 4 ++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lint-server.yml b/.github/workflows/lint-server.yml index ab55a3308e0..ea141a6037d 100644 --- a/.github/workflows/lint-server.yml +++ b/.github/workflows/lint-server.yml @@ -9,6 +9,7 @@ on: env: BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + USE_LOCAL_MATTERMOST-SERVER_REPO: true jobs: golangci: diff --git a/mattermost-plugin/build/gowork/main.go b/mattermost-plugin/build/gowork/main.go index 70414d2738b..cf66452c61d 100644 --- a/mattermost-plugin/build/gowork/main.go +++ b/mattermost-plugin/build/gowork/main.go @@ -42,17 +42,25 @@ func main() { } func makeGoWork(ci bool) string { + repos := []string{ + "mattermost-server", + "enterprise", + } + var b strings.Builder b.WriteString("go 1.18\n\n") b.WriteString("use ./mattermost-plugin\n") b.WriteString("use ./server\n") + for repoIdx := range repos { + if isEnvVarTrue(fmt.Sprintf("USE_LOCAL_%s_REPO", strings.ToUpper(repos[repoIdx])), true) { + b.WriteString(fmt.Sprintf("use ../%s\n", repos[repoIdx])) + } + } + if ci { b.WriteString("use ./linux\n") - } else { - b.WriteString("use ../mattermost-server\n") - b.WriteString("use ../enterprise\n") } return b.String() diff --git a/mattermost-plugin/product/api_adapter.go b/mattermost-plugin/product/api_adapter.go index 75dcbd5773d..e129d6852d2 100644 --- a/mattermost-plugin/product/api_adapter.go +++ b/mattermost-plugin/product/api_adapter.go @@ -34,7 +34,7 @@ type serviceAPIAdapter struct { func newServiceAPIAdapter(api *boardsProduct) *serviceAPIAdapter { return &serviceAPIAdapter{ api: api, - ctx: &request.Context{}, + ctx: request.EmptyContext(api.logger), } } @@ -94,7 +94,7 @@ func (a *serviceAPIAdapter) GetUserByEmail(email string) (*mm_model.User, error) } func (a *serviceAPIAdapter) UpdateUser(user *mm_model.User) (*mm_model.User, error) { - user, appErr := a.api.userService.UpdateUser(user, true) + user, appErr := a.api.userService.UpdateUser(a.ctx, user, true) return user, normalizeAppErr(appErr) } From 4f7ce070bccd39299f3384386ed10536d3412a1e Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Thu, 28 Jul 2022 15:01:22 +0530 Subject: [PATCH 17/22] Added confirmation dialog for unlinking a channel (#3432) * Added confirmation dialog for unlinking a channel * Updated texts --- webapp/i18n/en.json | 3 + .../__snapshots__/shareBoard.test.tsx.snap | 402 ++++++++++++++++++ .../shareBoard/channelPermissionsRow.tsx | 31 +- .../components/shareBoard/shareBoard.test.tsx | 55 +++ 4 files changed, 489 insertions(+), 2 deletions(-) diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index b2175437af3..8a4b32b50f5 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -361,6 +361,9 @@ "shareBoard.confirm-link-public-channel": "You're adding a public channel", "shareBoard.confirm-link-public-channel-button": "Yes, add public channel", "shareBoard.confirm-link-public-channel-subtext": "Anyone who joins that public channel will now get “Editor” access to the board, are you sure you want to proceed?", + "shareBoard.confirm-unlink.title": "Unlink channel from board", + "shareBoard.confirm-unlink.body": "When you unlink a channel from a board, all members of the channel (existing and new) will loose access to it unless they are given permission separately. {lineBreak} Are you sure you want to unlink it?", + "shareBoard.confirm-unlink.confirmBtnText": "Yes, unlink", "shareBoard.lastAdmin": "Boards must have at least one Administrator", "shareBoard.members-select-group": "Members", "tutorial_tip.finish_tour": "Done", diff --git a/webapp/src/components/shareBoard/__snapshots__/shareBoard.test.tsx.snap b/webapp/src/components/shareBoard/__snapshots__/shareBoard.test.tsx.snap index 649776346fa..60dc6a392e5 100644 --- a/webapp/src/components/shareBoard/__snapshots__/shareBoard.test.tsx.snap +++ b/webapp/src/components/shareBoard/__snapshots__/shareBoard.test.tsx.snap @@ -1,5 +1,255 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`src/components/shareBoard/shareBoard confirm unlinking linked channel 1`] = ` +
+
+
+
+ +
+
+`; + exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy link 1`] = `
+
+
+ + + +
+ + Dunder Mifflin Party Planing Committee + +
+
+
+ +
+
+
+
+ + + +
+ + Dunder Mifflin Party Planing Committee + +
+
+
+ +
+
+
+
+ + + +
+ + Dunder Mifflin Party Planing Committee + +
+
+
+ +
+
+
+
+ + + +
+ + Dunder Mifflin Party Planing Committee + +
+
+
+ +
+
{ const intl = useIntl() const board = useAppSelector(getCurrentBoard) const [linkedChannel, setLinkedChannel] = useState(null) + const [showUnlinkChannelConfirmation, setShowUnlinkChannelConfirmation] = useState(false) const onUnlinkBoard = async () => { const newBoard = createBoard(board) newBoard.channelId = '' mutator.updateBoard(newBoard, board, 'unlinked channel') + setShowUnlinkChannelConfirmation(false) } useEffect(() => { @@ -43,8 +46,32 @@ const ChannelPermissionsRow = (): JSX.Element => { return <> } + const confirmationDialog = ( + + }), + confirmButtonText: intl.formatMessage({ + id: 'shareBoard.confirm-unlink.confirmBtnText', + defaultMessage: 'Yes, unlink', + }), + onConfirm: onUnlinkBoard, + onClose: () => setShowUnlinkChannelConfirmation(false), + }} + /> + ) + return ( -
+
+ {showUnlinkChannelConfirmation && confirmationDialog}
{linkedChannel.type === 'P' && } @@ -69,7 +96,7 @@ const ChannelPermissionsRow = (): JSX.Element => { id='Unlink' icon={} name={intl.formatMessage({id: 'BoardMember.unlinkChannel', defaultMessage: 'Unlink'})} - onClick={onUnlinkBoard} + onClick={() => setShowUnlinkChannelConfirmation(true)} /> diff --git a/webapp/src/components/shareBoard/shareBoard.test.tsx b/webapp/src/components/shareBoard/shareBoard.test.tsx index 46d6229b3c2..357fc7a5c7a 100644 --- a/webapp/src/components/shareBoard/shareBoard.test.tsx +++ b/webapp/src/components/shareBoard/shareBoard.test.tsx @@ -77,21 +77,27 @@ board.cardProperties = [ ], }, ] +board.channelId = 'channel_1' + const activeView = TestBlockFactory.createBoardView(board) activeView.id = 'view1' activeView.fields.hiddenOptionIds = [] activeView.fields.visiblePropertyIds = ['property1'] activeView.fields.visibleOptionIds = ['value1'] + const fakeBoard = {id: board.id} activeView.boardId = fakeBoard.id + const card1 = TestBlockFactory.createCard(board) card1.id = 'card1' card1.title = 'card-1' card1.boardId = fakeBoard.id + const card2 = TestBlockFactory.createCard(board) card2.id = 'card2' card2.title = 'card-2' card2.boardId = fakeBoard.id + const card3 = TestBlockFactory.createCard(board) card3.id = 'card3' card3.title = 'card-3' @@ -187,6 +193,8 @@ describe('src/components/shareBoard/shareBoard', () => { viewId, workspaceId, } + + mockedOctoClient.getChannel.mockResolvedValue({type: 'P', display_name: 'Dunder Mifflin Party Planing Committee'} as Channel) }) afterEach(() => { @@ -335,6 +343,7 @@ describe('src/components/shareBoard/shareBoard', () => { expect(mockedOctoClient.setSharing).toBeCalledTimes(1) expect(container).toMatchSnapshot() }) + test('return shareBoard, and click switch', async () => { const sharing:ISharing = { id: boardId, @@ -374,6 +383,7 @@ describe('src/components/shareBoard/shareBoard', () => { expect(mockedOctoClient.getSharing).toBeCalledTimes(2) expect(container).toMatchSnapshot() }) + test('return shareBoardComponent and click Switch without sharing', async () => { const sharing:ISharing = { id: '', @@ -425,6 +435,7 @@ describe('src/components/shareBoard/shareBoard', () => { expect(mockedUtils.createGuid).toBeCalledTimes(1) expect(container).toMatchSnapshot() }) + test('should match snapshot with sharing and without workspaceId and subpath', async () => { w.baseURL = '/test-subpath/plugins/boards' const sharing:ISharing = { @@ -575,4 +586,48 @@ describe('src/components/shareBoard/shareBoard', () => { expect(container).toMatchSnapshot() }) + + test('confirm unlinking linked channel', async () => { + const sharing:ISharing = { + id: '', + enabled: false, + token: '', + } + mockedOctoClient.getSharing.mockResolvedValue(sharing) + mockedUtils.isFocalboardPlugin.mockReturnValue(true) + + let container: Element | DocumentFragment | null = null + await act(async () => { + const result = render( + wrapDNDIntl( + + + ), + {wrapper: MemoryRouter}, + ) + container = result.container + }) + + expect(container).toMatchSnapshot() + + const channelMenuBtn = container!.querySelector('.user-item.channel-item .MenuWrapper') + expect(channelMenuBtn).not.toBeNull() + userEvent.click(channelMenuBtn as Element) + + const unlinkOption = screen.getByText('Unlink') + expect(unlinkOption).not.toBeNull() + userEvent.click(unlinkOption) + + const unlinkConfirmationBtn = screen.getByText('Yes, unlink') + expect(unlinkConfirmationBtn).not.toBeNull() + userEvent.click(unlinkConfirmationBtn) + + expect(mockedOctoClient.patchBoard).toBeCalled() + + const closeButton = screen.getByRole('button', {name: 'Close dialog'}) + expect(closeButton).toBeDefined() + }) }) From b9ac00ef4e10a4dc3cbd8314af6392e35c0b7c15 Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Thu, 28 Jul 2022 21:06:09 +0530 Subject: [PATCH 18/22] Fixed issue with sidebar menu position (#3426) * Fixed issue with sidebar menu position * Removed duplicate code * Handled small screens as well * Fixed a class name --- .../components/sidebar/sidebarBoardItem.scss | 13 ++++++ .../components/sidebar/sidebarBoardItem.tsx | 11 +++-- .../components/sidebar/sidebarCategory.scss | 9 +++-- .../components/sidebar/sidebarCategory.tsx | 11 +++-- webapp/src/widgets/menu/menu.tsx | 28 +++++++++++-- webapp/src/widgets/menu/menuUtil.ts | 40 +++++++++++++++++++ webapp/src/widgets/menu/subMenuOption.tsx | 34 ++++++++++++++-- 7 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 webapp/src/widgets/menu/menuUtil.ts diff --git a/webapp/src/components/sidebar/sidebarBoardItem.scss b/webapp/src/components/sidebar/sidebarBoardItem.scss index 5a65db4fcb1..c40bf3dc23c 100644 --- a/webapp/src/components/sidebar/sidebarBoardItem.scss +++ b/webapp/src/components/sidebar/sidebarBoardItem.scss @@ -163,6 +163,19 @@ right: calc(100% - 480px + 50px); left: calc(240px - 50px); } + + .boardMoveToCategorySubmenu { + .menu-options { + max-height: 600px; + overflow-y: auto; + } + + @media only screen and (max-height: 768px) { + .menu-options { + max-height: min(350px, 50vh); + } + } + } } .team-sidebar + .product-wrapper { diff --git a/webapp/src/components/sidebar/sidebarBoardItem.tsx b/webapp/src/components/sidebar/sidebarBoardItem.tsx index 336792a6038..14aed53a682 100644 --- a/webapp/src/components/sidebar/sidebarBoardItem.tsx +++ b/webapp/src/components/sidebar/sidebarBoardItem.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useState} from 'react' +import React, {useCallback, useRef, useState} from 'react' import {useIntl} from 'react-intl' import {useHistory, useRouteMatch} from "react-router-dom" @@ -106,12 +106,15 @@ const SidebarBoardItem = (props: Props) => { }, [board.id]) + const boardItemRef = useRef(null) + const title = board.title || intl.formatMessage({id: 'Sidebar.untitled-board', defaultMessage: '(Untitled Board)'}) return ( <>
props.showBoard(board.id)} + ref={boardItemRef} >
{board.icon || } @@ -136,7 +139,8 @@ const SidebarBoardItem = (props: Props) => { }/> { } - position='bottom' + position='auto' > {generateMoveToCategoryOptions(board.id)} diff --git a/webapp/src/components/sidebar/sidebarCategory.scss b/webapp/src/components/sidebar/sidebarCategory.scss index 0f416ba99c5..a6b2b3c05c8 100644 --- a/webapp/src/components/sidebar/sidebarCategory.scss +++ b/webapp/src/components/sidebar/sidebarCategory.scss @@ -154,9 +154,12 @@ } } - .Menu.noselect.left { + .Menu.noselect:not(.SubMenu) { position: fixed; - right: calc(100% - 480px + 50px); - left: calc(240px - 50px); + + > .left { + right: calc(100% - 480px - 64px + 50px); + left: calc(64px + 240px - 50px); + } } } diff --git a/webapp/src/components/sidebar/sidebarCategory.tsx b/webapp/src/components/sidebar/sidebarCategory.tsx index bebf61bdab3..8da919bbff9 100644 --- a/webapp/src/components/sidebar/sidebarCategory.tsx +++ b/webapp/src/components/sidebar/sidebarCategory.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useState} from 'react' +import React, {useCallback, useRef, useState} from 'react' import {FormattedMessage, useIntl} from 'react-intl' import {generatePath, useHistory, useRouteMatch} from 'react-router-dom' @@ -59,6 +59,8 @@ const SidebarCategory = (props: Props) => { const team = useAppSelector(getCurrentTeam) const teamID = team?.id || '' + const menuWrapperRef = useRef(null) + const showBoard = useCallback((boardId) => { Utils.showBoard(boardId, match, history) props.hideSidebar() @@ -138,7 +140,7 @@ const SidebarCategory = (props: Props) => { }, [showBoard, deleteBoard, props.boards]) return ( -
+
@@ -156,7 +158,10 @@ const SidebarCategory = (props: Props) => { onToggle={(open) => setCategoryMenuOpen(open)} > }/> - + } + export default class Menu extends React.PureComponent { static Color = ColorOption static SubMenu = SubMenuOption @@ -27,14 +30,33 @@ export default class Menu extends React.PureComponent { static TextInput = textInputOption static Label = LabelOption + menuRef: React.RefObject + + constructor(props: Props) { + super(props) + + this.menuRef = React.createRef() + } + public state = { hovering: null, + menuStyle: {}, } public render(): JSX.Element { const {position, fixed, children} = this.props + + let style: CSSProperties = {} + if (position === 'auto' && this.props.parentRef) { + style = MenuUtil.openUp(this.props.parentRef).style + } + return ( -
+
{React.Children.map(children, (child) => ( diff --git a/webapp/src/widgets/menu/menuUtil.ts b/webapp/src/widgets/menu/menuUtil.ts new file mode 100644 index 00000000000..2f3d476db82 --- /dev/null +++ b/webapp/src/widgets/menu/menuUtil.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React, {CSSProperties} from 'react' + +/** + * Calculates if a menu should open aligned down or up around the `anchorRef` element. + * This should be used to make sure the menues are always fullly visible in cases + * when opening them close to the edges of screen. + * @param anchorRef ref of the element with respect to which the menu position is to be calculated. + * @param menuMargin a safe margin value to be ensured around the menu in the calculations. + * this ensures the menu stick to the edges of the screen ans has some space around for ease of use. + */ +function openUp(anchorRef: React.RefObject, menuMargin = 40): {openUp: boolean , style: CSSProperties} { + const ret = { + openUp: false, + style: {} as CSSProperties, + } + if (!anchorRef.current) { + return ret + } + + const boundingRect = anchorRef.current.getBoundingClientRect() + const y = typeof boundingRect?.y === 'undefined' ? boundingRect?.top : boundingRect.y + const windowHeight = window.innerHeight + const totalSpace = windowHeight - menuMargin + const spaceOnTop = y || 0 + const spaceOnBottom = totalSpace - spaceOnTop + ret.openUp = spaceOnTop > spaceOnBottom + if (ret.openUp) { + ret.style.bottom = spaceOnBottom + menuMargin + } else { + ret.style.top = spaceOnTop + menuMargin + } + + return ret +} + +export default { + openUp, +} diff --git a/webapp/src/widgets/menu/subMenuOption.tsx b/webapp/src/widgets/menu/subMenuOption.tsx index 15b71bacd13..66c1e9e677f 100644 --- a/webapp/src/widgets/menu/subMenuOption.tsx +++ b/webapp/src/widgets/menu/subMenuOption.tsx @@ -1,11 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useState, useContext} from 'react' +import React, {useEffect, useState, useContext, CSSProperties, useRef} from 'react' import SubmenuTriangleIcon from '../icons/submenuTriangle' +import MenuUtil from './menuUtil' + import Menu from '.' + import './subMenuOption.scss' export const HoveringContext = React.createContext(false) @@ -13,9 +16,10 @@ export const HoveringContext = React.createContext(false) type SubMenuOptionProps = { id: string name: string - position?: 'bottom' | 'top' | 'left' | 'left-bottom' + position?: 'bottom' | 'top' | 'left' | 'left-bottom' | 'auto' icon?: React.ReactNode children: React.ReactNode + className?: string } function SubMenuOption(props: SubMenuOptionProps): JSX.Element { @@ -30,22 +34,44 @@ function SubMenuOption(props: SubMenuOptionProps): JSX.Element { } }, [isHovering]) + const ref = useRef(null) + + const styleRef = useRef({}) + + useEffect(() => { + const newStyle: CSSProperties = {} + if (props.position === 'auto' && ref.current) { + const openUp = MenuUtil.openUp(ref) + if (openUp.openUp) { + newStyle.bottom = 0 + } else { + newStyle.top = 0 + } + } + + styleRef.current = newStyle + }, [ref.current]) + return (
{ e.preventDefault() e.stopPropagation() setIsOpen((open) => !open) }} + ref={ref} > {(props.position === 'left' || props.position === 'left-bottom') && } {props.icon ??
}
{props.name}
{props.position !== 'left' && props.position !== 'left-bottom' && } {isOpen && -
+
{props.children} From cd756a97f4e6cd0f347d86522f49bb7e9a40a750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Thu, 28 Jul 2022 18:45:00 +0200 Subject: [PATCH 19/22] Add the creator of the board as a administrator of the board if it still belongs to the channel (#3454) --- .../sqlstore/migrations/000018_add_teams_and_boards.up.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/services/store/sqlstore/migrations/000018_add_teams_and_boards.up.sql b/server/services/store/sqlstore/migrations/000018_add_teams_and_boards.up.sql index bc582ae02ed..ba1454d195b 100644 --- a/server/services/store/sqlstore/migrations/000018_add_teams_and_boards.up.sql +++ b/server/services/store/sqlstore/migrations/000018_add_teams_and_boards.up.sql @@ -302,7 +302,7 @@ INSERT INTO {{.prefix}}board_members ( SELECT B.Id, CM.UserId, CM.Roles, TRUE, TRUE, FALSE, FALSE FROM {{.prefix}}boards AS B INNER JOIN ChannelMembers as CM ON CM.ChannelId=B.channel_id - WHERE CM.SchemeAdmin=True + WHERE CM.SchemeAdmin=True OR (CM.UserId=B.created_by) ); {{end}} From f241fc19da83b12f5db73617227e71b39339193d Mon Sep 17 00:00:00 2001 From: Paul Esch-Laurent Date: Thu, 28 Jul 2022 12:10:18 -0500 Subject: [PATCH 20/22] Board switcher keyboard shortcut navigation (#3242) Co-authored-by: Mattermod --- .../boardsSwitcher/boardsSwitcher.tsx | 9 +++++ .../boardSwitcherDialog.tsx | 40 ++++++++++++++++++- .../components/searchDialog/searchDialog.scss | 5 ++- .../components/searchDialog/searchDialog.tsx | 35 ++++++++++++++-- webapp/src/constants.ts | 4 ++ 5 files changed, 86 insertions(+), 7 deletions(-) diff --git a/webapp/src/components/boardsSwitcher/boardsSwitcher.tsx b/webapp/src/components/boardsSwitcher/boardsSwitcher.tsx index 84bad593754..87abd620c90 100644 --- a/webapp/src/components/boardsSwitcher/boardsSwitcher.tsx +++ b/webapp/src/components/boardsSwitcher/boardsSwitcher.tsx @@ -40,12 +40,21 @@ const BoardsSwitcher = (props: Props): JSX.Element => { } } + const handleEscKeyPress = (e: KeyboardEvent) => { + if (Utils.isKeyPressed(e, Constants.keyCodes.ESC)) { + e.preventDefault() + setShowSwitcher(false) + } + } + useEffect(() => { document.addEventListener('keydown', handleQuickSwitchKeyPress) + document.addEventListener('keydown', handleEscKeyPress) // cleanup function return () => { document.removeEventListener('keydown', handleQuickSwitchKeyPress) + document.removeEventListener('keydown', handleEscKeyPress) } }, []) diff --git a/webapp/src/components/boardsSwitcherDialog/boardSwitcherDialog.tsx b/webapp/src/components/boardsSwitcherDialog/boardSwitcherDialog.tsx index c6e9fddf44d..d8e1ed2f830 100644 --- a/webapp/src/components/boardsSwitcherDialog/boardSwitcherDialog.tsx +++ b/webapp/src/components/boardsSwitcherDialog/boardSwitcherDialog.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {ReactNode} from 'react' +import React, {ReactNode, useRef, createRef, useState, useEffect, MutableRefObject} from 'react' import './boardSwitcherDialog.scss' import {useIntl} from 'react-intl' @@ -16,12 +16,18 @@ import {getAllTeams, getCurrentTeam, Team} from '../../store/teams' import {getMe} from '../../store/users' import {Utils} from '../../utils' import {BoardTypeOpen, BoardTypePrivate} from '../../blocks/board' +import { Constants } from '../../constants' type Props = { onClose: () => void } const BoardSwitcherDialog = (props: Props): JSX.Element => { + const [selected, setSelected] = useState(-1) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [refs, setRefs] = useState>(useRef([])) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [IDs, setIDs] = useState({}) const intl = useIntl() const team = useAppSelector(getCurrentTeam) const me = useAppSelector(getMe) @@ -58,14 +64,22 @@ const BoardSwitcherDialog = (props: Props): JSX.Element => { const items = await octoClient.searchAll(query) const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'}) - return items.map((item) => { + refs.current = items.map((_, i) => refs.current[i] ?? createRef()) + setRefs(refs) + return items.map((item, i) => { const resultTitle = item.title || untitledBoardTitle const teamTitle = teamsById[item.teamId].title + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setIDs((prevIDs: any) => ({ + ...prevIDs, + [i]: [item.teamId, item.id] + })) return (
selectBoard(item.teamId, item.id)} + ref={refs.current[i]} > {item.type === BoardTypeOpen && } {item.type === BoardTypePrivate && } @@ -76,12 +90,34 @@ const BoardSwitcherDialog = (props: Props): JSX.Element => { }) } + const handleEnterKeyPress = (e: KeyboardEvent) => { + if (Utils.isKeyPressed(e, Constants.keyCodes.ENTER) && selected > -1) { + e.preventDefault() + const [teamId, id] = IDs[selected] + selectBoard(teamId, id) + } + } + + useEffect(() => { + if (selected >= 0) + refs.current[selected].current.parentElement.focus() + + document.addEventListener('keydown', handleEnterKeyPress) + + // cleanup function + return () => { + document.removeEventListener('keydown', handleEnterKeyPress) + } + }, [selected, refs, IDs]) + return ( setSelected(n)} /> ) } diff --git a/webapp/src/components/searchDialog/searchDialog.scss b/webapp/src/components/searchDialog/searchDialog.scss index c3d057e20f0..fbc60899b24 100644 --- a/webapp/src/components/searchDialog/searchDialog.scss +++ b/webapp/src/components/searchDialog/searchDialog.scss @@ -58,12 +58,13 @@ padding: 0 24px; cursor: pointer; overflow: hidden; - + &.freesize { height: unset; } - &:hover { + &:hover, + &:focus { background: rgba(var(--center-channel-color-rgb), 0.08); } } diff --git a/webapp/src/components/searchDialog/searchDialog.tsx b/webapp/src/components/searchDialog/searchDialog.tsx index f01aa0d3d99..fc30cd77be3 100644 --- a/webapp/src/components/searchDialog/searchDialog.tsx +++ b/webapp/src/components/searchDialog/searchDialog.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {ReactNode, useMemo, useState} from 'react' +import React, {ReactNode, useEffect, useMemo, useState} from 'react' import './searchDialog.scss' import {FormattedMessage} from 'react-intl' @@ -10,6 +10,7 @@ import {debounce} from 'lodash' import Dialog from '../dialog' import {Utils} from '../../utils' import Search from '../../widgets/icons/search' +import { Constants } from '../../constants' type Props = { onClose: () => void @@ -17,9 +18,11 @@ type Props = { subTitle?: string | ReactNode searchHandler: (query: string) => Promise> initialData?: Array + selected: number + setSelected: (n: number) => void } -export const EmptySearch = () => ( +export const EmptySearch = (): JSX.Element => (
@@ -33,7 +36,7 @@ export const EmptySearch = () => (
) -export const EmptyResults = (props: {query: string}) => ( +export const EmptyResults = (props: {query: string}): JSX.Element => (
@@ -57,12 +60,14 @@ export const EmptyResults = (props: {query: string}) => ( ) const SearchDialog = (props: Props): JSX.Element => { + const {selected, setSelected} = props const [results, setResults] = useState>(props.initialData || []) const [isSearching, setIsSearching] = useState(false) const [searchQuery, setSearchQuery] = useState('') const searchHandler = async (query: string): Promise => { setIsSearching(true) + setSelected(-1) setSearchQuery(query) const searchResults = await props.searchHandler(query) setResults(searchResults) @@ -73,6 +78,29 @@ const SearchDialog = (props: Props): JSX.Element => { const emptyResult = results.length === 0 && !isSearching && searchQuery + const handleUpDownKeyPress = (e: KeyboardEvent) => { + if (Utils.isKeyPressed(e, Constants.keyCodes.DOWN)) { + e.preventDefault() + if (results.length > 0) + setSelected(((selected + 1) < results.length) ? (selected + 1) : selected) + } + + if (Utils.isKeyPressed(e, Constants.keyCodes.UP)) { + e.preventDefault() + if (results.length > 0) + setSelected(((selected - 1) > -1) ? (selected - 1) : selected) + } + } + + useEffect(() => { + document.addEventListener('keydown', handleUpDownKeyPress) + + // cleanup function + return () => { + document.removeEventListener('keydown', handleUpDownKeyPress) + } + }, [results, selected]) + return ( {
{result}
diff --git a/webapp/src/constants.ts b/webapp/src/constants.ts index 985638676ee..191ab58c485 100644 --- a/webapp/src/constants.ts +++ b/webapp/src/constants.ts @@ -161,6 +161,10 @@ class Constants { static readonly keyCodes: {[key: string]: [string, number]} = { COMPOSING: ['Composing', 229], + ESC: ['Esc', 27], + UP: ['Up', 38], + DOWN: ['Down', 40], + ENTER: ['Enter', 13], A: ['a', 65], B: ['b', 66], C: ['c', 67], From c4a90169aa108b8f669303dd7e2fa91a161c9106 Mon Sep 17 00:00:00 2001 From: Paul Esch-Laurent Date: Thu, 28 Jul 2022 16:47:54 -0500 Subject: [PATCH 21/22] fix(webapp): update template selector text (#3468) * fix(webapp): update template selector text * chore: appease the linter by removing unused vars --- webapp/i18n/en.json | 18 +++++++++--------- .../boardTemplateSelector.test.tsx.snap | 6 +++--- .../boardTemplateSelector.tsx | 2 +- webapp/src/components/workspace.tsx | 11 ++--------- 4 files changed, 15 insertions(+), 22 deletions(-) diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 8a4b32b50f5..4433e436566 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -19,10 +19,10 @@ "BoardTemplateSelector.add-template": "New template", "BoardTemplateSelector.create-empty-board": "Create empty board", "BoardTemplateSelector.delete-template": "Delete", - "BoardTemplateSelector.description": "Choose a template to help you get started. Easily customize the template to fit your needs, or create an empty board to start from scratch.", + "BoardTemplateSelector.description": "Add a board to the sidebar using any of the templates defined below or start from scratch.", "BoardTemplateSelector.edit-template": "Edit", - "BoardTemplateSelector.plugin.no-content-description": "Add a board to the sidebar using any of the templates defined below or start from scratch.{lineBreak} Members of \"{teamName}\" will have access to boards created here.", - "BoardTemplateSelector.plugin.no-content-title": "Create a Board in {teamName}", + "BoardTemplateSelector.plugin.no-content-description": "Add a board to the sidebar using any of the templates defined below or start from scratch.", + "BoardTemplateSelector.plugin.no-content-title": "Create a board", "BoardTemplateSelector.title": "Create a board", "BoardTemplateSelector.use-this-template": "Use this template", "BoardsSwitcher.Title": "Find Boards", @@ -344,6 +344,10 @@ "register.login-button": "or log in if you already have an account", "register.signup-title": "Sign up for your account", "rhs-boards.add": "Add", + "rhs-boards.dm": "DM", + "rhs-boards.gm": "GM", + "rhs-boards.header.dm": "this Direct Message", + "rhs-boards.header.gm": "this Group Message", "rhs-boards.last-update-at": "Last update at: {datetime}", "rhs-boards.link-boards-to-channel": "Link boards to {channelName}", "rhs-boards.linked-boards": "Linked boards", @@ -351,19 +355,15 @@ "rhs-boards.no-boards-linked-to-channel-description": "Boards is a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view.", "rhs-boards.unlink-board": "Unlink board", "rhs-channel-boards-header.title": "Boards", - "rhs-boards.dm": "DM", - "rhs-boards.header.dm": "this Direct Message", - "rhs-boards.gm": "GM", - "rhs-boards.header.gm": "this Group Message", "share-board.publish": "Publish", "share-board.share": "Share", "shareBoard.channels-select-group": "Channels", "shareBoard.confirm-link-public-channel": "You're adding a public channel", "shareBoard.confirm-link-public-channel-button": "Yes, add public channel", "shareBoard.confirm-link-public-channel-subtext": "Anyone who joins that public channel will now get “Editor” access to the board, are you sure you want to proceed?", - "shareBoard.confirm-unlink.title": "Unlink channel from board", "shareBoard.confirm-unlink.body": "When you unlink a channel from a board, all members of the channel (existing and new) will loose access to it unless they are given permission separately. {lineBreak} Are you sure you want to unlink it?", "shareBoard.confirm-unlink.confirmBtnText": "Yes, unlink", + "shareBoard.confirm-unlink.title": "Unlink channel from board", "shareBoard.lastAdmin": "Boards must have at least one Administrator", "shareBoard.members-select-group": "Members", "tutorial_tip.finish_tour": "Done", @@ -371,4 +371,4 @@ "tutorial_tip.ok": "Next", "tutorial_tip.out": "Opt out of these tips.", "tutorial_tip.seen": "Seen this before?" -} +} \ No newline at end of file diff --git a/webapp/src/components/boardTemplateSelector/__snapshots__/boardTemplateSelector.test.tsx.snap b/webapp/src/components/boardTemplateSelector/__snapshots__/boardTemplateSelector.test.tsx.snap index f2b48f4871c..81d1377c041 100644 --- a/webapp/src/components/boardTemplateSelector/__snapshots__/boardTemplateSelector.test.tsx.snap +++ b/webapp/src/components/boardTemplateSelector/__snapshots__/boardTemplateSelector.test.tsx.snap @@ -29,7 +29,7 @@ exports[`components/boardTemplateSelector/boardTemplateSelector a focalboard Plu

- Choose a template to help you get started. Easily customize the template to fit your needs, or create an empty board to start from scratch. + Add a board to the sidebar using any of the templates defined below or start from scratch.

- Choose a template to help you get started. Easily customize the template to fit your needs, or create an empty board to start from scratch. + Add a board to the sidebar using any of the templates defined below or start from scratch.

- Choose a template to help you get started. Easily customize the template to fit your needs, or create an empty board to start from scratch. + Add a board to the sidebar using any of the templates defined below or start from scratch.

{ {description || ( )}

diff --git a/webapp/src/components/workspace.tsx b/webapp/src/components/workspace.tsx index 5ba1ac33933..0a98a467a0d 100644 --- a/webapp/src/components/workspace.tsx +++ b/webapp/src/components/workspace.tsx @@ -4,7 +4,6 @@ import React, {useCallback, useEffect, useState} from 'react' import {generatePath, useRouteMatch, useHistory} from 'react-router-dom' import {FormattedMessage} from 'react-intl' -import {getCurrentTeam} from '../store/teams' import {getCurrentBoard, isLoadingBoard, getTemplates} from '../store/boards' import {refreshCards, getCardLimitTimestamp, getCurrentBoardHiddenCardsCount, setLimitTimestamp, getCurrentViewCardsSortedFilteredAndGrouped, setCurrent as setCurrentCard} from '../store/cards' import { @@ -33,7 +32,6 @@ type Props = { } function CenterContent(props: Props) { - const team = useAppSelector(getCurrentTeam) const isLoading = useAppSelector(isLoadingBoard) const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string, channelId?: string}>() const board = useAppSelector(getCurrentBoard) @@ -115,18 +113,13 @@ function CenterContent(props: Props) { title={ } description={ {team?.title}, - lineBreak:
, - }} + defaultMessage='Add a board to the sidebar using any of the templates defined below or start from scratch.' /> } channelId={match.params.channelId} From 2fd22851237fed1ec21bf2292bae53d02bc710a2 Mon Sep 17 00:00:00 2001 From: Ben Cuan Date: Fri, 29 Jul 2022 09:16:23 -0700 Subject: [PATCH 22/22] Add enablePublicSharedBoards to admin guide (#3441) --- website/site/content/guide/admin/_index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/website/site/content/guide/admin/_index.md b/website/site/content/guide/admin/_index.md index 7aa7ccc8f8c..d5d53f2f1d6 100644 --- a/website/site/content/guide/admin/_index.md +++ b/website/site/content/guide/admin/_index.md @@ -25,6 +25,7 @@ Personal server settings are stored in `config.json` and are read when the serve | localOnly | Only allow connections from localhost | `false` | enableLocalMode | Enable admin APIs on local Unix port | `true` | localModeSocketLocation | Location of local Unix port | `/var/tmp/focalboard_local.socket` +| enablePublicSharedBoards | Enable publishing boards for public access | `false` ## Resetting passwords