diff --git a/import/asana/importAsana.ts b/import/asana/importAsana.ts index 66636b8d3..ab54878bc 100644 --- a/import/asana/importAsana.ts +++ b/import/asana/importAsana.ts @@ -5,6 +5,7 @@ import minimist from 'minimist' import {exit} from 'process' import {ArchiveUtils} from '../util/archive' import {Block} from '../../webapp/src/blocks/block' +import {Board} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {createBoardView} from '../../webapp/src/blocks/boardView' import {createCard} from '../../webapp/src/blocks/card' @@ -49,11 +50,11 @@ function main() { const input = JSON.parse(inputData) as Asana // Convert - const blocks = convert(input) + const [boards, blocks] = convert(input) // Save output // TODO: Stream output - const outputData = ArchiveUtils.buildBlockArchive(blocks) + const outputData = ArchiveUtils.buildBlockArchive(boards, blocks) fs.writeFileSync(outputFile, outputData) console.log(`Exported to ${outputFile}`) @@ -88,22 +89,22 @@ function getSections(input: Asana, projectId: string): Workspace[] { return [...sectionMap.values()] } -function convert(input: Asana): Block[] { +function convert(input: Asana): [Board[], Block[]] { const projects = getProjects(input) if (projects.length < 1) { console.error('No projects found') - return [] + return [[],[]] } // TODO: Handle multiple projects const project = projects[0] + const boards: Board[] = [] const blocks: Block[] = [] // Board const board = createBoard() console.log(`Board: ${project.name}`) - board.rootId = board.id board.title = project.name // Convert sections (columns) to a Select property @@ -130,14 +131,14 @@ function convert(input: Asana): Block[] { options } board.cardProperties = [cardProperty] - blocks.push(board) + boards.push(board) // Board view const view = createBoardView() view.title = 'Board View' view.fields.viewType = 'board' - view.rootId = board.id view.parentId = board.id + view.boardId = board.id blocks.push(view) // Cards @@ -146,7 +147,7 @@ function convert(input: Asana): Block[] { const outCard = createCard() outCard.title = card.name - outCard.rootId = board.id + outCard.boardId = board.id outCard.parentId = board.id // Map lists to Select property options @@ -168,8 +169,8 @@ function convert(input: Asana): Block[] { // console.log(`\t${card.notes}`) const text = createTextBlock() text.title = card.notes - text.rootId = board.id text.parentId = outCard.id + text.boardId = board.id blocks.push(text) outCard.fields.contentOrder = [text.id] @@ -179,7 +180,7 @@ function convert(input: Asana): Block[] { console.log('') console.log(`Found ${input.data.length} card(s).`) - return blocks + return [boards, blocks] } function showHelp() { diff --git a/import/jira/jiraImporter.test.ts b/import/jira/jiraImporter.test.ts index b50fe7c7e..503d7cfd6 100644 --- a/import/jira/jiraImporter.test.ts +++ b/import/jira/jiraImporter.test.ts @@ -3,7 +3,7 @@ import {run} from './jiraImporter' import * as fs from 'fs' -import {ArchiveUtils} from '../../webapp/src/blocks/archive' +import {ArchiveUtils} from '../util/archive' const inputFile = './test/jira-export.xml' const outputFile = './test/jira.focalboard' @@ -27,10 +27,6 @@ describe('import from Jira', () => { expect(blocks).toEqual( expect.arrayContaining([ - expect.objectContaining({ - title: 'Jira import', - type: 'board' - }), expect.objectContaining({ title: 'Board View', type: 'view' diff --git a/import/jira/jiraImporter.ts b/import/jira/jiraImporter.ts index 19140f96a..748dc6267 100644 --- a/import/jira/jiraImporter.ts +++ b/import/jira/jiraImporter.ts @@ -4,6 +4,7 @@ import * as fs from 'fs' import {exit} from 'process' import {ArchiveUtils} from '../util/archive' import {Block} from '../../webapp/src/blocks/block' +import {Board} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {createBoardView} from '../../webapp/src/blocks/boardView' import {Card, createCard} from '../../webapp/src/blocks/card' @@ -70,23 +71,23 @@ async function run(inputFile: string, outputFile: string): Promise { // console.dir(items); // Convert - const blocks = convert(items) + const [boards, blocks] = convert(items) // Save output // TODO: Stream output - const outputData = ArchiveUtils.buildBlockArchive(blocks) + const outputData = ArchiveUtils.buildBlockArchive(boards, blocks) fs.writeFileSync(outputFile, outputData) console.log(`Exported ${blocks.length} block(s) to ${outputFile}`) return blocks.length } -function convert(items: any[]) { +function convert(items: any[]): [Board[], Block[]] { + const boards: Board[] = [] const blocks: Block[] = [] // Board const board = createBoard() - board.rootId = board.id board.title = 'Jira import' // Compile standard properties @@ -126,13 +127,13 @@ function convert(items: any[]) { } board.cardProperties.push(createdDateProperty) - blocks.push(board) + boards.push(board) // Board view const view = createBoardView() view.title = 'Board View' view.fields.viewType = 'board' - view.rootId = board.id + view.boardId = board.id view.parentId = board.id blocks.push(view) @@ -145,7 +146,7 @@ function convert(items: any[]) { const card = createCard() card.title = item.summary - card.rootId = board.id + card.boardId = board.id card.parentId = board.id // Map standard properties @@ -169,7 +170,7 @@ function convert(items: any[]) { console.log(`\t${description}`) const text = createTextBlock() text.title = description - text.rootId = board.id + text.boardId = board.id text.parentId = card.id blocks.push(text) @@ -179,7 +180,7 @@ function convert(items: any[]) { blocks.push(card) } - return blocks + return [boards, blocks] } function buildCardPropertyFromValues(propertyName: string, allValues: string[]) { diff --git a/import/nextcloud-deck/importDeck.ts b/import/nextcloud-deck/importDeck.ts index 744d23f67..1617bc166 100644 --- a/import/nextcloud-deck/importDeck.ts +++ b/import/nextcloud-deck/importDeck.ts @@ -3,8 +3,9 @@ import * as fs from 'fs' import minimist from 'minimist' import {exit} from 'process' -import {ArchiveUtils} from '../../webapp/src/blocks/archive' +import {ArchiveUtils} from '../util/archive' import {Block} from '../../webapp/src/blocks/block' +import {Board as FBBoard} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {createBoardView} from '../../webapp/src/blocks/boardView' import {createCard} from '../../webapp/src/blocks/card' @@ -69,10 +70,10 @@ async function main() { })) // Convert - const blocks = convert(board, stacks) + const [boards, blocks] = convert(board, stacks) // // Save output - const outputData = ArchiveUtils.buildBlockArchive(blocks) + const outputData = ArchiveUtils.buildBlockArchive(boards, blocks) fs.writeFileSync(outputFile, outputData) console.log(`Exported to ${outputFile}`) @@ -85,13 +86,13 @@ async function selectBoard(deckClient: NextcloudDeckClient): Promise { return readline.questionInt("Enter Board ID: ") } -function convert(deckBoard: Board, stacks: Stack[]): Block[] { +function convert(deckBoard: Board, stacks: Stack[]): [FBBoard[], Block[]] { + const boards: FBBoard[] = [] const blocks: Block[] = [] // Board const board = createBoard() console.log(`Board: ${deckBoard.title}`) - board.rootId = board.id board.title = deckBoard.title let colorIndex = 0 @@ -145,14 +146,14 @@ function convert(deckBoard: Board, stacks: Stack[]): Block[] { options: [] } - board.fields.cardProperties = [stackProperty, labelProperty, dueDateProperty] - blocks.push(board) + board.cardProperties = [stackProperty, labelProperty, dueDateProperty] + boards.push(board) // Board view const view = createBoardView() view.title = 'Board View' view.fields.viewType = 'board' - view.rootId = board.id + view.boardId = board.id view.parentId = board.id blocks.push(view) @@ -164,7 +165,7 @@ function convert(deckBoard: Board, stacks: Stack[]): Block[] { const outCard = createCard() outCard.title = card.title - outCard.rootId = board.id + outCard.boardId = board.id outCard.parentId = board.id // Map Stacks to Select property options @@ -189,7 +190,7 @@ function convert(deckBoard: Board, stacks: Stack[]): Block[] { if (card.description) { const text = createTextBlock() text.title = card.description - text.rootId = board.id + text.boardId = board.id text.parentId = outCard.id blocks.push(text) @@ -200,7 +201,7 @@ function convert(deckBoard: Board, stacks: Stack[]): Block[] { card.comments?.forEach(comment => { const commentBlock = createCommentBlock() commentBlock.title = comment.message - commentBlock.rootId = board.id + commentBlock.boardId = board.id commentBlock.parentId = outCard.id blocks.push(commentBlock) }) @@ -210,7 +211,7 @@ function convert(deckBoard: Board, stacks: Stack[]): Block[] { console.log('') console.log(`Transformed Board ${deckBoard.title} into ${blocks.length} blocks.`) - return blocks + return [boards, blocks] } function showHelp() { diff --git a/import/notion/importNotion.ts b/import/notion/importNotion.ts index 361c84848..85f7508c8 100644 --- a/import/notion/importNotion.ts +++ b/import/notion/importNotion.ts @@ -5,6 +5,7 @@ import path from 'path' import {exit} from 'process' import {ArchiveUtils} from '../util/archive' import {Block} from '../../webapp/src/blocks/block' +import {Board} from '../../webapp/src/blocks/board' import {IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {createBoardView} from '../../webapp/src/blocks/boardView' import {createCard} from '../../webapp/src/blocks/card' @@ -70,11 +71,11 @@ async function main() { markdownFolder = path.join(inputFolder, basename) // Convert - const blocks = convert(input, title) + const [boards, blocks] = convert(input, title) // Save output // TODO: Stream output - const outputData = ArchiveUtils.buildBlockArchive(blocks) + const outputData = ArchiveUtils.buildBlockArchive(boards, blocks) fs.writeFileSync(outputFile, outputData) console.log(`Exported to ${outputFile}`) @@ -117,13 +118,13 @@ function getColumns(input: any[]) { return keys.slice(1) } -function convert(input: any[], title: string): Block[] { +function convert(input: any[], title: string): [Board[], Block[]] { + const boards: Board[] = [] const blocks: Block[] = [] // Board const board = createBoard() console.log(`Board: ${title}`) - board.rootId = board.id board.title = title // Each column is a card property @@ -140,13 +141,13 @@ function convert(input: any[], title: string): Block[] { // Set all column types to select // TODO: Detect column type - blocks.push(board) + boards.push(board) // Board view const view = createBoardView() view.title = 'Board View' view.fields.viewType = 'board' - view.rootId = board.id + view.boardId = board.id view.parentId = board.id blocks.push(view) @@ -166,7 +167,7 @@ function convert(input: any[], title: string): Block[] { const outCard = createCard() outCard.title = title - outCard.rootId = board.id + outCard.boardId = board.id outCard.parentId = board.id // Card properties, skip first key which is the title @@ -201,7 +202,7 @@ function convert(input: any[], title: string): Block[] { console.log(`Markdown: ${markdown.length} bytes`) const text = createTextBlock() text.title = markdown - text.rootId = board.id + text.boardId = board.id text.parentId = outCard.id blocks.push(text) @@ -212,7 +213,7 @@ function convert(input: any[], title: string): Block[] { console.log('') console.log(`Found ${input.length} card(s).`) - return blocks + return [boards, blocks] } function showHelp() { diff --git a/import/todoist/importTodoist.ts b/import/todoist/importTodoist.ts index 047d2a14b..f7dd3f59b 100644 --- a/import/todoist/importTodoist.ts +++ b/import/todoist/importTodoist.ts @@ -5,6 +5,7 @@ import minimist from 'minimist' import {exit} from 'process' import {ArchiveUtils} from '../util/archive' import {Block} from '../../webapp/src/blocks/block' +import {Board} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {createBoardView} from '../../webapp/src/blocks/boardView' import {createCard} from '../../webapp/src/blocks/card' @@ -56,31 +57,34 @@ function main() { const inputData = fs.readFileSync(inputFile, 'utf-8') const input = JSON.parse(inputData) as Todoist + const boards = [] as Board[] const blocks = [] as Block[] input.projects.forEach(project => { - blocks.push(...convert(input, project)) + const [brds, blks] = convert(input, project) + boards.push(...brds) + blocks.push(...blks) }) // Save output // TODO: Stream output - const outputData = ArchiveUtils.buildBlockArchive(blocks) + const outputData = ArchiveUtils.buildBlockArchive(boards, blocks) fs.writeFileSync(outputFile, outputData) console.log(`Exported to ${outputFile}`) } -function convert(input: Todoist, project: Project): Block[] { +function convert(input: Todoist, project: Project): [Board[], Block[]] { + const boards: Board[] = [] const blocks: Block[] = [] if (project.name === 'Inbox') { - return blocks + return [boards, blocks] } // Board const board = createBoard() console.log(`Board: ${project.name}`) - board.rootId = board.id board.title = project.name board.description = project.name @@ -115,13 +119,13 @@ function convert(input: Todoist, project: Project): Block[] { options } board.cardProperties = [cardProperty] - blocks.push(board) + boards.push(board) // Board view const view = createBoardView() view.title = 'Board View' view.fields.viewType = 'board' - view.rootId = board.id + view.boardId = board.id view.parentId = board.id blocks.push(view) @@ -130,7 +134,7 @@ function convert(input: Todoist, project: Project): Block[] { cards.forEach(card => { const outCard = createCard() outCard.title = card.content - outCard.rootId = board.id + outCard.boardId = board.id outCard.parentId = board.id // Map lists to Select property options @@ -148,14 +152,14 @@ function convert(input: Todoist, project: Project): Block[] { // console.log(`\t${card.desc}`) const text = createTextBlock() text.title = getCardDescription(input, card).join('\n\n') - text.rootId = board.id + text.boardId = board.id text.parentId = outCard.id blocks.push(text) outCard.fields.contentOrder = [text.id] }) - return blocks + return [boards, blocks] } function getProjectColumns(input: Todoist, project: Project): Array
{ diff --git a/import/trello/importTrello.ts b/import/trello/importTrello.ts index eacae528a..1794b30ee 100644 --- a/import/trello/importTrello.ts +++ b/import/trello/importTrello.ts @@ -5,6 +5,7 @@ import minimist from 'minimist' import {exit} from 'process' import {ArchiveUtils} from '../util/archive' import {Block} from '../../webapp/src/blocks/block' +import {Board} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {createBoardView} from '../../webapp/src/blocks/boardView' import {createCard} from '../../webapp/src/blocks/card' @@ -50,23 +51,23 @@ function main() { const input = JSON.parse(inputData) as Trello // Convert - const blocks = convert(input) + const [boards, blocks] = convert(input) // Save output // TODO: Stream output - const outputData = ArchiveUtils.buildBlockArchive(blocks) + const outputData = ArchiveUtils.buildBlockArchive(boards, blocks) fs.writeFileSync(outputFile, outputData) console.log(`Exported to ${outputFile}`) } -function convert(input: Trello): Block[] { +function convert(input: Trello): [Board[], Block[]] { + const boards: Board[] = [] const blocks: Block[] = [] // Board const board = createBoard() console.log(`Board: ${input.name}`) - board.rootId = board.id board.title = input.name board.description = input.desc @@ -93,13 +94,13 @@ function convert(input: Trello): Block[] { options } board.cardProperties = [cardProperty] - blocks.push(board) + boards.push(board) // Board view const view = createBoardView() view.title = 'Board View' view.fields.viewType = 'board' - view.rootId = board.id + view.boardId = board.id view.parentId = board.id blocks.push(view) @@ -109,7 +110,7 @@ function convert(input: Trello): Block[] { const outCard = createCard() outCard.title = card.name - outCard.rootId = board.id + outCard.boardId = board.id outCard.parentId = board.id // Map lists to Select property options @@ -130,7 +131,7 @@ function convert(input: Trello): Block[] { // console.log(`\t${card.desc}`) const text = createTextBlock() text.title = card.desc - text.rootId = board.id + text.boardId = board.id text.parentId = outCard.id blocks.push(text) @@ -150,7 +151,7 @@ function convert(input: Trello): Block[] { } else { checkBlock.fields.value = false } - checkBlock.rootId = outCard.rootId + checkBlock.boardId = board.id checkBlock.parentId = outCard.id blocks.push(checkBlock) @@ -164,7 +165,7 @@ function convert(input: Trello): Block[] { console.log('') console.log(`Found ${input.cards.length} card(s).`) - return blocks + return [boards, blocks] } function showHelp() { diff --git a/import/util/archive.ts b/import/util/archive.ts index e4502d3f4..ad7401083 100644 --- a/import/util/archive.ts +++ b/import/util/archive.ts @@ -1,25 +1,31 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Block} from '../../webapp/src/blocks/block' +import {Board} from '../../webapp/src/blocks/board' interface ArchiveHeader { version: number date: number } +// This schema allows the expansion of additional line types in the future interface ArchiveLine { type: string, data: unknown, } -// This schema allows the expansion of additional line types in the future interface BlockArchiveLine extends ArchiveLine { type: 'block', data: Block } +interface BoardArchiveLine extends ArchiveLine { + type: 'board', + data: Board +} + class ArchiveUtils { - static buildBlockArchive(blocks: readonly Block[]): string { + static buildBlockArchive(boards: readonly Board[], blocks: readonly Block[]): string { const header: ArchiveHeader = { version: 1, date: Date.now(), @@ -27,6 +33,17 @@ class ArchiveUtils { const headerString = JSON.stringify(header) let content = headerString + '\n' + + for (const board of boards) { + const line: BoardArchiveLine = { + type: 'board', + data: board, + } + const lineString = JSON.stringify(line) + content += lineString + content += '\n' + } + for (const block of blocks) { const line: BlockArchiveLine = { type: 'block', diff --git a/server/api/api.go b/server/api/api.go index 44bb148c3..3a04a3028 100644 --- a/server/api/api.go +++ b/server/api/api.go @@ -87,14 +87,9 @@ func (a *API) RegisterRoutes(r *mux.Router) { apiv1.HandleFunc("/boards/{boardID}/blocks/{blockID}", a.sessionRequired(a.handleDeleteBlock)).Methods("DELETE") apiv1.HandleFunc("/boards/{boardID}/blocks/{blockID}", a.sessionRequired(a.handlePatchBlock)).Methods("PATCH") apiv1.HandleFunc("/boards/{boardID}/blocks/{blockID}/undelete", a.sessionRequired(a.handleUndeleteBlock)).Methods("POST") - apiv1.HandleFunc("/boards/{boardID}/blocks/{blockID}/subtree", a.attachSession(a.handleGetSubTree, false)).Methods("GET") apiv1.HandleFunc("/boards/{boardID}/blocks/{blockID}/duplicate", a.attachSession(a.handleDuplicateBlock, false)).Methods("POST") apiv1.HandleFunc("/boards/{boardID}/metadata", a.sessionRequired(a.handleGetBoardMetadata)).Methods("GET") - // Import&Export APIs - apiv1.HandleFunc("/boards/{boardID}/blocks/export", a.sessionRequired(a.handleExport)).Methods("GET") - apiv1.HandleFunc("/boards/{boardID}/blocks/import", a.sessionRequired(a.handleImport)).Methods("POST") - // Member APIs apiv1.HandleFunc("/boards/{boardID}/members", a.sessionRequired(a.handleGetMembersForBoard)).Methods("GET") apiv1.HandleFunc("/boards/{boardID}/members", a.sessionRequired(a.handleAddMember)).Methods("POST") @@ -155,7 +150,7 @@ func (a *API) RegisterRoutes(r *mux.Router) { // archives apiv1.HandleFunc("/boards/{boardID}/archive/export", a.sessionRequired(a.handleArchiveExportBoard)).Methods("GET") - apiv1.HandleFunc("/boards/{boardID}/archive/import", a.sessionRequired(a.handleArchiveImport)).Methods("POST") + apiv1.HandleFunc("/teams/{teamID}/archive/import", a.sessionRequired(a.handleArchiveImport)).Methods("POST") } func (a *API) RegisterAdminRoutes(r *mux.Router) { @@ -363,23 +358,6 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) { auditRec.Success() } -func stampModificationMetadata(r *http.Request, blocks []model.Block, auditRec *audit.Record) { - userID := getUserID(r) - if userID == model.SingleUser { - userID = "" - } - - now := utils.GetMillis() - for i := range blocks { - blocks[i].ModifiedBy = userID - blocks[i].UpdateAt = now - - if auditRec != nil { - auditRec.AddMeta("block_"+strconv.FormatInt(int64(i), 10), blocks[i]) - } - } -} - func (a *API) handleCreateCategory(w http.ResponseWriter, r *http.Request) { requestBody, err := ioutil.ReadAll(r.Body) if err != nil { @@ -1219,287 +1197,6 @@ func (a *API) handlePatchBlocks(w http.ResponseWriter, r *http.Request) { auditRec.Success() } -func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) { - // swagger:operation GET /api/v1/boards/{boardID}/blocks/{blockID}/subtree getSubTree - // - // Returns the blocks of a subtree - // - // --- - // produces: - // - application/json - // parameters: - // - name: boardID - // in: path - // description: Board ID - // required: true - // type: string - // - name: blockID - // in: path - // description: The ID of the root block of the subtree - // required: true - // type: string - // - name: l - // in: query - // description: The number of levels to return. 2 or 3. Defaults to 2. - // required: false - // type: integer - // minimum: 2 - // maximum: 3 - // security: - // - BearerAuth: [] - // responses: - // '200': - // description: success - // schema: - // type: array - // items: - // "$ref": "#/definitions/Block" - // default: - // description: internal error - // schema: - // "$ref": "#/definitions/ErrorResponse" - - userID := getUserID(r) - vars := mux.Vars(r) - boardID := vars["boardID"] - blockID := vars["blockID"] - - if !a.hasValidReadTokenForBoard(r, boardID) && !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { - a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"}) - return - } - - query := r.URL.Query() - levels, err := strconv.ParseInt(query.Get("l"), 10, 32) - if err != nil { - levels = 2 - } - - if levels != 2 && levels != 3 { - a.logger.Error("Invalid levels", mlog.Int64("levels", levels)) - a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "invalid levels", nil) - return - } - - auditRec := a.makeAuditRecord(r, "getSubTree", audit.Fail) - defer a.audit.LogRecord(audit.LevelRead, auditRec) - auditRec.AddMeta("boardID", boardID) - auditRec.AddMeta("blockID", blockID) - - blocks, err := a.app.GetSubTree(boardID, blockID, int(levels), model.QuerySubtreeOptions{}) - if err != nil { - a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) - return - } - - a.logger.Debug("GetSubTree", - mlog.Int64("levels", levels), - mlog.String("boardID", boardID), - mlog.String("blockID", blockID), - mlog.Int("block_count", len(blocks)), - ) - json, err := json.Marshal(blocks) - if err != nil { - a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) - return - } - - jsonBytesResponse(w, http.StatusOK, json) - - auditRec.AddMeta("blockCount", len(blocks)) - auditRec.Success() -} - -func (a *API) handleExport(w http.ResponseWriter, r *http.Request) { - // swagger:operation GET /api/v1/boards/{boardID}/blocks/export exportBlocks - // - // Returns all blocks of a board - // - // --- - // produces: - // - application/json - // parameters: - // - name: boardID - // in: path - // description: Board ID - // required: true - // type: string - // security: - // - BearerAuth: [] - // responses: - // '200': - // description: success - // schema: - // type: array - // items: - // "$ref": "#/definitions/Block" - // default: - // description: internal error - // schema: - // "$ref": "#/definitions/ErrorResponse" - - userID := getUserID(r) - vars := mux.Vars(r) - boardID := vars["boardID"] - - if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { - a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"}) - return - } - - query := r.URL.Query() - rootID := query.Get("root_id") - - auditRec := a.makeAuditRecord(r, "export", audit.Fail) - defer a.audit.LogRecord(audit.LevelRead, auditRec) - auditRec.AddMeta("boardID", boardID) - auditRec.AddMeta("rootID", rootID) - - var blocks []model.Block - var err error - if rootID == "" { - blocks, err = a.app.GetBlocksForBoard(boardID) - } else { - blocks, err = a.app.GetBlocksWithBoardID(boardID) - } - if err != nil { - a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) - return - } - - a.logger.Debug("raw blocks", mlog.Int("block_count", len(blocks))) - auditRec.AddMeta("rawCount", len(blocks)) - - blocks = filterOrphanBlocks(blocks) - - a.logger.Debug("EXPORT filtered blocks", mlog.Int("block_count", len(blocks))) - auditRec.AddMeta("filteredCount", len(blocks)) - - json, err := json.Marshal(blocks) - if err != nil { - a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) - return - } - - jsonBytesResponse(w, http.StatusOK, json) - - auditRec.Success() -} - -func filterOrphanBlocks(blocks []model.Block) (ret []model.Block) { - queue := make([]model.Block, 0) - childrenOfBlockWithID := make(map[string]*[]model.Block) - - // Build the trees from nodes - for _, block := range blocks { - if len(block.ParentID) == 0 { - // Queue root blocks to process first - queue = append(queue, block) - } else { - siblings := childrenOfBlockWithID[block.ParentID] - if siblings != nil { - *siblings = append(*siblings, block) - } else { - siblings := []model.Block{block} - childrenOfBlockWithID[block.ParentID] = &siblings - } - } - } - - // Map the trees to an array, which skips orphaned nodes - blocks = make([]model.Block, 0) - for len(queue) > 0 { - block := queue[0] - queue = queue[1:] // dequeue - blocks = append(blocks, block) - children := childrenOfBlockWithID[block.ID] - if children != nil { - queue = append(queue, *children...) - } - } - - return blocks -} - -func (a *API) handleImport(w http.ResponseWriter, r *http.Request) { - // swagger:operation POST /api/v1/boards/{boardID}/blocks/import importBlocks - // - // Import blocks on a given board - // - // --- - // produces: - // - application/json - // parameters: - // - name: boardID - // in: path - // description: Board ID - // required: true - // type: string - // - name: Body - // in: body - // description: array of blocks to import - // required: true - // schema: - // type: array - // items: - // "$ref": "#/definitions/Block" - // security: - // - BearerAuth: [] - // responses: - // '200': - // description: success - // default: - // description: internal error - // schema: - // "$ref": "#/definitions/ErrorResponse" - - userID := getUserID(r) - vars := mux.Vars(r) - boardID := vars["boardID"] - - if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) { - a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to make board changes"}) - return - } - - requestBody, err := ioutil.ReadAll(r.Body) - if err != nil { - a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) - return - } - - var blocks []model.Block - - err = json.Unmarshal(requestBody, &blocks) - if err != nil { - a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) - return - } - - auditRec := a.makeAuditRecord(r, "import", audit.Fail) - defer a.audit.LogRecord(audit.LevelModify, auditRec) - auditRec.AddMeta("boardID", boardID) - - // all blocks should now be part of the board that they're being - // imported onto - for i := range blocks { - blocks[i].BoardID = boardID - } - - stampModificationMetadata(r, blocks, auditRec) - - if _, err = a.app.InsertBlocks(model.GenerateBlockIDs(blocks, a.logger), userID, false); err != nil { - a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) - return - } - - jsonStringResponse(w, http.StatusOK, "{}") - - a.logger.Debug("IMPORT BlockIDs", mlog.Int("block_count", len(blocks))) - auditRec.AddMeta("blockCount", len(blocks)) - auditRec.Success() -} - // Sharing func (a *API) handleGetSharing(w http.ResponseWriter, r *http.Request) { @@ -1799,6 +1496,9 @@ func (a *API) handlePostTeamRegenerateSignupToken(w http.ResponseWriter, r *http // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" + if a.MattermostAuth { + a.errorResponse(w, r.URL.Path, http.StatusNotImplemented, "not permitted in plugin mode", nil) + } team, err := a.app.GetRootTeam() if err != nil { @@ -1824,7 +1524,7 @@ func (a *API) handlePostTeamRegenerateSignupToken(w http.ResponseWriter, r *http // File upload func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) { - // swagger:operation GET /boards/{boardID}/{rootID}/{fileID} getFile + // swagger:operation GET "api/v1/files/teams/{teamID}/{boardID}/{filename} getFile // // Returns the contents of an uploaded file // @@ -1835,19 +1535,19 @@ func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) { // - image/png // - image/gif // parameters: + // - name: teamID + // in: path + // description: Team ID + // required: true + // type: string // - name: boardID // in: path // description: Board ID // required: true // type: string - // - name: rootID + // - name: filename // in: path - // description: ID of the root block - // required: true - // type: string - // - name: fileID - // in: path - // description: ID of the file + // description: name of the file // required: true // type: string // security: @@ -1865,7 +1565,8 @@ func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) { filename := vars["filename"] userID := getUserID(r) - if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { + hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID) + if !hasValidReadToken && !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"}) return } @@ -2188,7 +1889,7 @@ func (a *API) handleGetTemplates(w http.ResponseWriter, r *http.Request) { auditRec.AddMeta("teamID", teamID) // retrieve boards list - boards, err := a.app.GetTemplateBoards(teamID) + boards, err := a.app.GetTemplateBoards(teamID, userID) if err != nil { a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) return @@ -2831,8 +2532,7 @@ func (a *API) handleDuplicateBoard(w http.ResponseWriter, r *http.Request) { return } - hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID) - if userID == "" && !hasValidReadToken { + if userID == "" { a.errorResponse(w, r.URL.Path, http.StatusUnauthorized, "", PermissionError{"access denied to board"}) return } @@ -2847,17 +2547,15 @@ func (a *API) handleDuplicateBoard(w http.ResponseWriter, r *http.Request) { return } - if !hasValidReadToken { - if board.Type == model.BoardTypePrivate { - if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { - a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"}) - return - } - } else { - if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) { - a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"}) - return - } + if board.Type == model.BoardTypePrivate { + if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { + a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"}) + return + } + } else { + if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) { + a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"}) + return } } @@ -2926,8 +2624,7 @@ func (a *API) handleDuplicateBlock(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() asTemplate := query.Get("asTemplate") - hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID) - if userID == "" && !hasValidReadToken { + if userID == "" { a.errorResponse(w, r.URL.Path, http.StatusUnauthorized, "", PermissionError{"access denied to board"}) return } diff --git a/server/api/archive.go b/server/api/archive.go index 190c9c750..dd5e25bf3 100644 --- a/server/api/archive.go +++ b/server/api/archive.go @@ -8,6 +8,8 @@ import ( "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/audit" + + "github.com/mattermost/mattermost-server/v6/shared/mlog" ) const ( @@ -103,6 +105,9 @@ func (a *API) handleArchiveExportTeam(w http.ResponseWriter, r *http.Request) { // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" + if a.MattermostAuth { + a.errorResponse(w, r.URL.Path, http.StatusNotImplemented, "not permitted in plugin mode", nil) + } vars := mux.Vars(r) teamID := vars["teamID"] @@ -143,7 +148,7 @@ func (a *API) handleArchiveExportTeam(w http.ResponseWriter, r *http.Request) { } func (a *API) handleArchiveImport(w http.ResponseWriter, r *http.Request) { - // swagger:operation POST /api/v1/boards/{boardID}/archive/import archiveImport + // swagger:operation POST /api/v1/teams/{teamID}/archive/import archiveImport // // Import an archive of boards. // @@ -153,9 +158,9 @@ func (a *API) handleArchiveImport(w http.ResponseWriter, r *http.Request) { // consumes: // - multipart/form-data // parameters: - // - name: boardID + // - name: teamID // in: path - // description: Workspace ID + // description: Team ID // required: true // type: string // - name: file @@ -198,6 +203,10 @@ func (a *API) handleArchiveImport(w http.ResponseWriter, r *http.Request) { } if err := a.app.ImportArchive(file, opt); err != nil { + a.logger.Debug("Error importing archive", + mlog.String("team_id", teamID), + mlog.Err(err), + ) a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) return } diff --git a/server/api/auth.go b/server/api/auth.go index b5fa26789..b1b7e6ec5 100644 --- a/server/api/auth.go +++ b/server/api/auth.go @@ -166,6 +166,9 @@ func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) { // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" + if a.MattermostAuth { + a.errorResponse(w, r.URL.Path, http.StatusNotImplemented, "not permitted in plugin mode", nil) + } if len(a.singleUserToken) > 0 { // Not permitted in single-user mode @@ -228,6 +231,9 @@ func (a *API) handleLogout(w http.ResponseWriter, r *http.Request) { // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" + if a.MattermostAuth { + a.errorResponse(w, r.URL.Path, http.StatusNotImplemented, "not permitted in plugin mode", nil) + } if len(a.singleUserToken) > 0 { // Not permitted in single-user mode @@ -278,6 +284,9 @@ func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) { // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" + if a.MattermostAuth { + a.errorResponse(w, r.URL.Path, http.StatusNotImplemented, "not permitted in plugin mode", nil) + } if len(a.singleUserToken) > 0 { // Not permitted in single-user mode @@ -377,6 +386,9 @@ func (a *API) handleChangePassword(w http.ResponseWriter, r *http.Request) { // description: internal error // schema: // "$ref": "#/definitions/ErrorResponse" + if a.MattermostAuth { + a.errorResponse(w, r.URL.Path, http.StatusNotImplemented, "not permitted in plugin mode", nil) + } if len(a.singleUserToken) > 0 { // Not permitted in single-user mode @@ -458,6 +470,18 @@ func (a *API) attachSession(handler func(w http.ResponseWriter, r *http.Request) CreateAt: now, UpdateAt: now, } + + user, err := a.app.GetUser(userID) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusUnauthorized, "", err) + return + } + + if user.IsGuest { + a.errorResponse(w, r.URL.Path, http.StatusUnauthorized, "guests not supported", nil) + return + } + ctx := context.WithValue(r.Context(), sessionContextKey, session) handler(w, r.WithContext(ctx)) return diff --git a/server/app/blocks.go b/server/app/blocks.go index 3cd480764..32c168aab 100644 --- a/server/app/blocks.go +++ b/server/app/blocks.go @@ -234,15 +234,6 @@ func (a *App) CopyCardFiles(sourceBoardID string, blocks []model.Block) error { return nil } -func (a *App) GetSubTree(boardID, blockID string, levels int, opts model.QuerySubtreeOptions) ([]model.Block, error) { - // Only 2 or 3 levels are supported for now - if levels >= 3 { - return a.store.GetSubTree3(boardID, blockID, opts) - } - - return a.store.GetSubTree2(boardID, blockID, opts) -} - func (a *App) GetBlockByID(blockID string) (*model.Block, error) { return a.store.GetBlock(blockID) } diff --git a/server/app/boards.go b/server/app/boards.go index 8500d4d51..b44151851 100644 --- a/server/app/boards.go +++ b/server/app/boards.go @@ -69,6 +69,21 @@ func (a *App) GetBoardMetadata(boardID string) (*model.Board, *model.BoardMetada return board, &boardMetadata, nil } +// getBoardForBlock returns the board that owns the specified block. +func (a *App) getBoardForBlock(blockID string) (*model.Board, error) { + block, err := a.GetBlockByID(blockID) + if err != nil { + return nil, fmt.Errorf("cannot get block %s: %w", blockID, err) + } + + board, err := a.GetBoard(block.BoardID) + if err != nil { + return nil, fmt.Errorf("cannot get board %s: %w", block.BoardID, err) + } + + return board, nil +} + func (a *App) getBoardHistory(boardID string, latest bool) (*model.Board, error) { opts := model.QueryBlockHistoryOptions{ Limit: 1, @@ -150,8 +165,8 @@ func (a *App) GetBoardsForUserAndTeam(userID, teamID string) ([]*model.Board, er return a.store.GetBoardsForUserAndTeam(userID, teamID) } -func (a *App) GetTemplateBoards(teamID string) ([]*model.Board, error) { - return a.store.GetTemplateBoards(teamID) +func (a *App) GetTemplateBoards(teamID, userID string) ([]*model.Board, error) { + return a.store.GetTemplateBoards(teamID, userID) } func (a *App) CreateBoard(board *model.Board, userID string, addMember bool) (*model.Board, error) { diff --git a/server/app/import.go b/server/app/import.go index 18fce2ad7..945f5b077 100644 --- a/server/app/import.go +++ b/server/app/import.go @@ -120,11 +120,12 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str var boardID string lineNum := 1 + firstLine := true for { line, errRead := readLine(lineReader) if len(line) != 0 { var skip bool - if lineNum == 1 { + if firstLine { // first line might be a header tag (old archive format) if strings.HasPrefix(string(line), legacyFileBegin) { skip = true @@ -138,7 +139,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str } // first line must be a board - if lineNum == 1 && archiveLine.Type == "block" { + if firstLine && archiveLine.Type == "block" { archiveLine.Type = "board_block" } @@ -179,6 +180,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str default: return "", model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type) } + firstLine = false } } @@ -204,6 +206,18 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str return "", fmt.Errorf("error inserting archive blocks: %w", err) } + // add user to all the new boards. + for _, board := range boardsAndBlocks.Boards { + boardMember := &model.BoardMember{ + BoardID: board.ID, + UserID: opt.ModifiedBy, + SchemeAdmin: true, + } + if _, err := a.AddMemberToBoard(boardMember); err != nil { + return "", fmt.Errorf("cannot add member to board: %w", err) + } + } + // find new board id for _, board := range boardsAndBlocks.Boards { return board.ID, nil diff --git a/server/app/import_test.go b/server/app/import_test.go new file mode 100644 index 000000000..0f9858a35 --- /dev/null +++ b/server/app/import_test.go @@ -0,0 +1,69 @@ +package app + +import ( + "bytes" + "testing" + + "github.com/golang/mock/gomock" + "github.com/mattermost/focalboard/server/model" + "github.com/stretchr/testify/require" +) + +func TestApp_ImportArchive(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + board := &model.Board{ + ID: "d14b9df9-1f31-4732-8a64-92bc7162cd28", + TeamID: "test-team", + Title: "Cross-Functional Project Plan", + } + + block := model.Block{ + ID: "2c1873e0-1484-407d-8b2c-3c3b5a2a9f9e", + ParentID: board.ID, + Type: model.TypeView, + BoardID: board.ID, + } + + babs := &model.BoardsAndBlocks{ + Boards: []*model.Board{board}, + Blocks: []model.Block{block}, + } + + boardMember := &model.BoardMember{ + BoardID: board.ID, + UserID: "user", + } + + t.Run("import asana archive", func(t *testing.T) { + r := bytes.NewReader([]byte(asana)) + opts := model.ImportArchiveOptions{ + TeamID: "test-team", + ModifiedBy: "user", + } + + th.Store.EXPECT().CreateBoardsAndBlocks(gomock.AssignableToTypeOf(&model.BoardsAndBlocks{}), "user").Return(babs, nil) + th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{boardMember}, nil) + th.Store.EXPECT().GetBoard(board.ID).Return(board, nil) + th.Store.EXPECT().GetMemberForBoard(board.ID, "user").Return(boardMember, nil) + + err := th.App.ImportArchive(r, opts) + require.NoError(t, err, "import archive should not fail") + }) +} + +//nolint:lll +const asana = `{"version":1,"date":1614714686842} +{"type":"block","data":{"id":"d14b9df9-1f31-4732-8a64-92bc7162cd28","fields":{"icon":"","description":"","cardProperties":[{"id":"3bdcbaeb-bc78-4884-8531-a0323b74676a","name":"Section","type":"select","options":[{"id":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732","value":"Planning","color":"propColorGray"},{"id":"454559bb-b788-4ff6-873e-04def8491d2c","value":"Milestones","color":"propColorBrown"},{"id":"deaab476-c690-48df-828f-725b064dc476","value":"Next steps","color":"propColorOrange"},{"id":"2138305a-3157-461c-8bbe-f19ebb55846d","value":"Comms Plan","color":"propColorYellow"}]}]},"createAt":1614714686836,"updateAt":1614714686836,"deleteAt":0,"schema":1,"parentId":"","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"board","title":"Cross-Functional Project Plan"}} +{"type":"block","data":{"id":"2c1873e0-1484-407d-8b2c-3c3b5a2a9f9e","fields":{"sortOptions":[],"visiblePropertyIds":[],"visibleOptionIds":[],"hiddenOptionIds":[],"filter":{"operation":"and","filters":[]},"cardOrder":[],"columnWidths":{},"viewType":"board"},"createAt":1614714686840,"updateAt":1614714686840,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"view","title":"Board View"}} +{"type":"block","data":{"id":"520c332b-adf5-4a32-88ab-43655c8b6aa2","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732"},"contentOrder":["deb3966c-6d56-43b1-8e95-36806877ce81"]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[READ ME] - Instructions for using this template"}} +{"type":"block","data":{"id":"deb3966c-6d56-43b1-8e95-36806877ce81","fields":{},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"520c332b-adf5-4a32-88ab-43655c8b6aa2","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"text","title":"This project template is set up in List View with sections and Asana-created Custom Fields to help you track your team's work. We've provided some example content in this template to get you started, but you should add tasks, change task names, add more Custom Fields, and change any other info to make this project your own.\n\nSend feedback about this template: https://asa.na/templatesfeedback"}} +{"type":"block","data":{"id":"be791f66-a5e5-4408-82f6-cb1280f5bc45","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732"},"contentOrder":["2688b31f-e7ff-4de1-87ae-d4b5570f8712"]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"Redesign the landing page of our website"}} +{"type":"block","data":{"id":"2688b31f-e7ff-4de1-87ae-d4b5570f8712","fields":{},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"be791f66-a5e5-4408-82f6-cb1280f5bc45","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"text","title":"Redesign the landing page to focus on the main persona."}} +{"type":"block","data":{"id":"98f74948-1700-4a3c-8cc2-8bb632499def","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"454559bb-b788-4ff6-873e-04def8491d2c"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Consider trying a new email marketing service"}} +{"type":"block","data":{"id":"142fba5d-05e6-4865-83d9-b3f54d9de96e","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"454559bb-b788-4ff6-873e-04def8491d2c"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Budget finalization"}} +{"type":"block","data":{"id":"ca6670b1-b034-4e42-8971-c659b478b9e0","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"deaab476-c690-48df-828f-725b064dc476"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Find a venue for the holiday party"}} +{"type":"block","data":{"id":"db1dd596-0999-4741-8b05-72ca8e438e31","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"deaab476-c690-48df-828f-725b064dc476"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Approve campaign copy"}} +{"type":"block","data":{"id":"16861c05-f31f-46af-8429-80a87b5aa93a","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"2138305a-3157-461c-8bbe-f19ebb55846d"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Send out updated attendee list"}} +` diff --git a/server/app/onboarding.go b/server/app/onboarding.go index f4e1bab7c..176854fc8 100644 --- a/server/app/onboarding.go +++ b/server/app/onboarding.go @@ -46,14 +46,14 @@ func (a *App) PrepareOnboardingTour(userID string, teamID string) (string, strin } func (a *App) getOnboardingBoardID() (string, error) { - boards, err := a.store.GetTemplateBoards(globalTeamID) + boards, err := a.store.GetTemplateBoards(globalTeamID, "") if err != nil { return "", err } var onboardingBoardID string for _, block := range boards { - if block.Title == WelcomeBoardTitle { + if block.Title == WelcomeBoardTitle && block.TeamID == globalTeamID { onboardingBoardID = block.ID break } diff --git a/server/app/onboarding_test.go b/server/app/onboarding_test.go index 75f20c589..927c2125e 100644 --- a/server/app/onboarding_test.go +++ b/server/app/onboarding_test.go @@ -25,7 +25,7 @@ func TestPrepareOnboardingTour(t *testing.T) { IsTemplate: true, } - th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{&welcomeBoard}, nil) + th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil) th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}}, nil, nil) th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(2) @@ -70,7 +70,7 @@ func TestCreateWelcomeBoard(t *testing.T) { TeamID: "0", IsTemplate: true, } - th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{&welcomeBoard}, nil) + th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil) th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false). Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}}, nil, nil) th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(2) @@ -91,7 +91,7 @@ func TestCreateWelcomeBoard(t *testing.T) { t.Run("template doesn't contain a board", func(t *testing.T) { teamID := testTeamID - th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{}, nil) + th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{}, nil) boardID, err := th.App.createWelcomeBoard("user_id_1", teamID) assert.Error(t, err) assert.Empty(t, boardID) @@ -105,7 +105,7 @@ func TestCreateWelcomeBoard(t *testing.T) { TeamID: teamID, IsTemplate: true, } - th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{&welcomeBoard}, nil) + th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil) boardID, err := th.App.createWelcomeBoard("user_id_1", "workspace_id_1") assert.Error(t, err) assert.Empty(t, boardID) @@ -123,7 +123,7 @@ func TestGetOnboardingBoardID(t *testing.T) { TeamID: "0", IsTemplate: true, } - th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{&welcomeBoard}, nil) + th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil) onboardingBoardID, err := th.App.getOnboardingBoardID() assert.NoError(t, err) @@ -131,7 +131,7 @@ func TestGetOnboardingBoardID(t *testing.T) { }) t.Run("no blocks found", func(t *testing.T) { - th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{}, nil) + th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{}, nil) onboardingBoardID, err := th.App.getOnboardingBoardID() assert.Error(t, err) @@ -145,7 +145,7 @@ func TestGetOnboardingBoardID(t *testing.T) { TeamID: "0", IsTemplate: true, } - th.Store.EXPECT().GetTemplateBoards("0").Return([]*model.Board{&welcomeBoard}, nil) + th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil) onboardingBoardID, err := th.App.getOnboardingBoardID() assert.Error(t, err) diff --git a/server/app/subscriptions.go b/server/app/subscriptions.go index aaf8ec316..76ea2a603 100644 --- a/server/app/subscriptions.go +++ b/server/app/subscriptions.go @@ -3,6 +3,8 @@ package app import ( "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" + + "github.com/mattermost/mattermost-server/v6/shared/mlog" ) func (a *App) CreateSubscription(sub *model.Subscription) (*model.Subscription, error) { @@ -37,5 +39,15 @@ func (a *App) notifySubscriptionChanged(subscription *model.Subscription) { if a.notifications == nil { return } - a.notifications.BroadcastSubscriptionChange(subscription) + + board, err := a.getBoardForBlock(subscription.BlockID) + if err != nil { + a.logger.Error("Error notifying subscription change", + mlog.String("subscriber_id", subscription.SubscriberID), + mlog.String("block_id", subscription.BlockID), + mlog.Err(err), + ) + } + + a.notifications.BroadcastSubscriptionChange(board.TeamID, subscription) } diff --git a/server/app/templates.go b/server/app/templates.go index 2b05827de..96c1e7453 100644 --- a/server/app/templates.go +++ b/server/app/templates.go @@ -23,7 +23,7 @@ func (a *App) InitTemplates() error { // initializeTemplates imports default templates if the boards table is empty. func (a *App) initializeTemplates() (bool, error) { - boards, err := a.store.GetTemplateBoards(globalTeamID) + boards, err := a.store.GetTemplateBoards(globalTeamID, "") if err != nil { return false, fmt.Errorf("cannot initialize templates: %w", err) } diff --git a/server/app/templates_test.go b/server/app/templates_test.go index 37defa865..1045b5ea8 100644 --- a/server/app/templates_test.go +++ b/server/app/templates_test.go @@ -34,14 +34,21 @@ func TestApp_initializeTemplates(t *testing.T) { Blocks: []model.Block{block}, } + boardMember := &model.BoardMember{ + BoardID: board.ID, + UserID: "test-user", + } + t.Run("Needs template init", func(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() - th.Store.EXPECT().GetTemplateBoards(globalTeamID).Return([]*model.Board{}, nil) + th.Store.EXPECT().GetTemplateBoards(globalTeamID, "").Return([]*model.Board{}, nil) th.Store.EXPECT().RemoveDefaultTemplates([]*model.Board{}).Return(nil) th.Store.EXPECT().CreateBoardsAndBlocks(gomock.Any(), gomock.Any()).AnyTimes().Return(boardsAndBlocks, nil) th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{}, nil) + th.Store.EXPECT().GetBoard(board.ID).AnyTimes().Return(board, nil) + th.Store.EXPECT().GetMemberForBoard(gomock.Any(), gomock.Any()).AnyTimes().Return(boardMember, nil) th.FilesBackend.On("WriteFile", mock.Anything, mock.Anything).Return(int64(1), nil) @@ -54,7 +61,7 @@ func TestApp_initializeTemplates(t *testing.T) { th, tearDown := SetupTestHelper(t) defer tearDown() - th.Store.EXPECT().GetTemplateBoards(globalTeamID).Return([]*model.Board{board}, nil) + th.Store.EXPECT().GetTemplateBoards(globalTeamID, "").Return([]*model.Board{board}, nil) done, err := th.App.initializeTemplates() require.NoError(t, err, "initializeTemplates should not error") diff --git a/server/auth/auth_test.go b/server/auth/auth_test.go index 04b6b0218..4103edba5 100644 --- a/server/auth/auth_test.go +++ b/server/auth/auth_test.go @@ -42,7 +42,7 @@ func setupTestHelper(t *testing.T) *TestHelper { newAuth := New(&cfg, mockStore, localpermissions.New(mockPermissions, logger)) // called during default template setup for every test - mockStore.EXPECT().GetTemplateBoards(gomock.Any()).AnyTimes() + mockStore.EXPECT().GetTemplateBoards("0", "").AnyTimes() mockStore.EXPECT().RemoveDefaultTemplates(gomock.Any()).AnyTimes() mockStore.EXPECT().InsertBlock(gomock.Any(), gomock.Any()).AnyTimes() diff --git a/server/client/client.go b/server/client/client.go index 789d8f7e3..2d5547c7f 100644 --- a/server/client/client.go +++ b/server/client/client.go @@ -164,10 +164,6 @@ func (c *Client) GetBlockRoute(boardID, blockID string) string { return fmt.Sprintf("%s/%s", c.GetBlocksRoute(boardID), blockID) } -func (c *Client) GetSubtreeRoute(boardID, blockID string) string { - return fmt.Sprintf("%s/subtree", c.GetBlockRoute(boardID, blockID)) -} - func (c *Client) GetBoardsRoute() string { return "/boards" } @@ -297,16 +293,6 @@ func (c *Client) DeleteBlock(boardID, blockID string) (bool, *Response) { return true, BuildResponse(r) } -func (c *Client) GetSubtree(boardID, blockID string) ([]model.Block, *Response) { - r, err := c.DoAPIGet(c.GetSubtreeRoute(boardID, blockID), "") - if err != nil { - return nil, BuildErrorResponse(r, err) - } - defer closeBody(r) - - return model.BlocksFromJSON(r.Body), BuildResponse(r) -} - // Boards and blocks. func (c *Client) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks) (*model.BoardsAndBlocks, *Response) { r, err := c.DoAPIPost(c.GetBoardsAndBlocksRoute(), toJSON(bab)) diff --git a/server/integrationtests/blocks_test.go b/server/integrationtests/blocks_test.go index 573bd1144..00eca9cfd 100644 --- a/server/integrationtests/blocks_test.go +++ b/server/integrationtests/blocks_test.go @@ -429,18 +429,4 @@ func TestGetSubtree(t *testing.T) { } require.Contains(t, blockIDs, parentBlockID) }) - - t.Run("Get subtree for parent ID", func(t *testing.T) { - blocks, resp := th.Client.GetSubtree(board.ID, parentBlockID) - require.NoError(t, resp.Error) - require.Len(t, blocks, 3) - - blockIDs := make([]string, len(blocks)) - for i, b := range blocks { - blockIDs[i] = b.ID - } - require.Contains(t, blockIDs, parentBlockID) - require.Contains(t, blockIDs, childBlockID1) - require.Contains(t, blockIDs, childBlockID2) - }) } diff --git a/server/model/user.go b/server/model/user.go index c4c115d0f..f34383365 100644 --- a/server/model/user.go +++ b/server/model/user.go @@ -55,6 +55,10 @@ type User struct { // If the user is a bot or not // required: true IsBot bool `json:"is_bot"` + + // If the user is a guest or not + // required: true + IsGuest bool `json:"is_guest"` } // UserPropPatch is a user property patch diff --git a/server/services/notify/notifysubscriptions/subscriptions_backend.go b/server/services/notify/notifysubscriptions/subscriptions_backend.go index b6e3e98e3..4b543ba7c 100644 --- a/server/services/notify/notifysubscriptions/subscriptions_backend.go +++ b/server/services/notify/notifysubscriptions/subscriptions_backend.go @@ -205,7 +205,7 @@ func (b *Backend) OnMention(userID string, evt notify.BlockChangeEvent) { } // BroadcastSubscriptionChange sends a websocket message with details of the changed subscription to all -// connected users in the workspace. -func (b *Backend) BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) { - b.wsAdapter.BroadcastSubscriptionChange(workspaceID, subscription) +// connected users in the team. +func (b *Backend) BroadcastSubscriptionChange(teamID string, subscription *model.Subscription) { + b.wsAdapter.BroadcastSubscriptionChange(teamID, subscription) } diff --git a/server/services/notify/service.go b/server/services/notify/service.go index 5735496f7..4f9611427 100644 --- a/server/services/notify/service.go +++ b/server/services/notify/service.go @@ -31,7 +31,7 @@ type BlockChangeEvent struct { } type SubscriptionChangeNotifier interface { - BroadcastSubscriptionChange(subscription *model.Subscription) + BroadcastSubscriptionChange(teamID string, subscription *model.Subscription) } // Backend provides an interface for sending notifications. @@ -113,7 +113,7 @@ func (s *Service) BlockChanged(evt BlockChangeEvent) { // BroadcastSubscriptionChange sends a websocket message with details of the changed subscription to all // connected users in the workspace. -func (s *Service) BroadcastSubscriptionChange(subscription *model.Subscription) { +func (s *Service) BroadcastSubscriptionChange(teamID string, subscription *model.Subscription) { s.mux.RLock() backends := make([]Backend, len(s.backends)) copy(backends, s.backends) @@ -125,7 +125,7 @@ func (s *Service) BroadcastSubscriptionChange(subscription *model.Subscription) mlog.String("block_id", subscription.BlockID), mlog.String("subscriber_id", subscription.SubscriberID), ) - scn.BroadcastSubscriptionChange(subscription) + scn.BroadcastSubscriptionChange(teamID, subscription) } } } diff --git a/server/services/store/mattermostauthlayer/mattermostauthlayer.go b/server/services/store/mattermostauthlayer/mattermostauthlayer.go index c02b6f3f4..baa925ee7 100644 --- a/server/services/store/mattermostauthlayer/mattermostauthlayer.go +++ b/server/services/store/mattermostauthlayer/mattermostauthlayer.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" + mmModel "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" sq "github.com/Masterminds/squirrel" @@ -12,7 +13,6 @@ import ( "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/utils" - mmModel "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/mlog" ) @@ -55,7 +55,8 @@ func (s *MattermostAuthLayer) GetRegisteredUserCount() (int, error) { query := s.getQueryBuilder(). Select("count(*)"). From("Users"). - Where(sq.Eq{"deleteAt": 0}) + Where(sq.Eq{"deleteAt": 0}). + Where(sq.NotEq{"roles": "system_guest"}) row := query.QueryRow() var count int @@ -67,67 +68,31 @@ func (s *MattermostAuthLayer) GetRegisteredUserCount() (int, error) { return count, nil } -func (s *MattermostAuthLayer) getUserByCondition(condition sq.Eq) (*model.User, error) { - users, err := s.getUsersByCondition(condition) - if err != nil { - return nil, err - } - - var user *model.User - for _, u := range users { - user = u - break - } - - return user, nil -} - -func (s *MattermostAuthLayer) getUsersByCondition(condition sq.Eq) (map[string]*model.User, error) { - query := s.getQueryBuilder(). - Select("u.id", "u.username", "u.email", "u.password", "u.MFASecret as mfa_secret", "u.AuthService as auth_service", "COALESCE(u.AuthData, '') as auth_data", - "u.props", "u.CreateAt as create_at", "u.UpdateAt as update_at", "u.DeleteAt as delete_at", "b.UserId IS NOT NULL AS is_bot"). - From("Users as u"). - LeftJoin("Bots b ON ( b.UserId = u.ID )"). - Where(sq.Eq{"u.deleteAt": 0}). - Where(condition) - row, err := query.Query() - if err != nil { - return nil, err - } - - users := map[string]*model.User{} - - for row.Next() { - user := model.User{} - - var propsBytes []byte - err := row.Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.MfaSecret, &user.AuthService, - &user.AuthData, &propsBytes, &user.CreateAt, &user.UpdateAt, &user.DeleteAt, &user.IsBot) - if err != nil { - return nil, err - } - - err = json.Unmarshal(propsBytes, &user.Props) - if err != nil { - return nil, err - } - - users[user.ID] = &user - } - - return users, nil -} - func (s *MattermostAuthLayer) GetUserByID(userID string) (*model.User, error) { - return s.getUserByCondition(sq.Eq{"id": userID}) + mmuser, err := s.pluginAPI.GetUser(userID) + if err != nil { + return nil, err + } + user := mmUserToFbUser(mmuser) + return &user, nil } func (s *MattermostAuthLayer) GetUserByEmail(email string) (*model.User, error) { - return s.getUserByCondition(sq.Eq{"email": email}) + mmuser, err := s.pluginAPI.GetUserByEmail(email) + if err != nil { + return nil, err + } + user := mmUserToFbUser(mmuser) + return &user, nil } func (s *MattermostAuthLayer) GetUserByUsername(username string) (*model.User, error) { - return s.getUserByCondition(sq.Eq{"username": username}) + mmuser, err := s.pluginAPI.GetUserByUsername(username) + if err != nil { + return nil, err + } + user := mmUserToFbUser(mmuser) + return &user, nil } func (s *MattermostAuthLayer) CreateUser(user *model.User) error { @@ -293,6 +258,7 @@ func (s *MattermostAuthLayer) GetUsersByTeam(teamID string) ([]*model.User, erro Join("TeamMembers as tm ON tm.UserID = u.ID"). LeftJoin("Bots b ON ( b.UserId = Users.ID )"). Where(sq.Eq{"u.deleteAt": 0}). + Where(sq.NotEq{"u.roles": "system_guest"}). Where(sq.Eq{"tm.TeamId": teamID}) rows, err := query.Query() @@ -324,6 +290,7 @@ func (s *MattermostAuthLayer) SearchUsersByTeam(teamID string, searchQuery strin sq.Like{"u.lastname": "%" + searchQuery + "%"}, }). Where(sq.Eq{"tm.TeamId": teamID}). + Where(sq.NotEq{"u.roles": "system_guest"}). OrderBy("u.username"). Limit(10) @@ -390,6 +357,32 @@ func (s *MattermostAuthLayer) CreatePrivateWorkspace(userID string) (string, err return channel.Id, nil } +func mmUserToFbUser(mmUser *mmModel.User) model.User { + props := map[string]interface{}{} + for key, value := range mmUser.Props { + props[key] = value + } + authData := "" + if mmUser.AuthData != nil { + authData = *mmUser.AuthData + } + return model.User{ + ID: mmUser.Id, + Username: mmUser.Username, + Email: mmUser.Email, + Password: mmUser.Password, + MfaSecret: mmUser.MfaSecret, + AuthService: mmUser.AuthService, + AuthData: authData, + Props: props, + CreateAt: mmUser.CreateAt, + UpdateAt: mmUser.UpdateAt, + DeleteAt: mmUser.DeleteAt, + IsBot: mmUser.IsBot, + IsGuest: mmUser.IsGuest(), + } +} + func (s *MattermostAuthLayer) GetLicense() *mmModel.License { return s.pluginAPI.GetLicense() } diff --git a/server/services/store/mockstore/mockstore.go b/server/services/store/mockstore/mockstore.go index 6bb48ae64..462b401a7 100644 --- a/server/services/store/mockstore/mockstore.go +++ b/server/services/store/mockstore/mockstore.go @@ -881,18 +881,18 @@ func (mr *MockStoreMockRecorder) GetTeamsForUser(arg0 interface{}) *gomock.Call } // GetTemplateBoards mocks base method. -func (m *MockStore) GetTemplateBoards(arg0 string) ([]*model.Board, error) { +func (m *MockStore) GetTemplateBoards(arg0, arg1 string) ([]*model.Board, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetTemplateBoards", arg0) + ret := m.ctrl.Call(m, "GetTemplateBoards", arg0, arg1) ret0, _ := ret[0].([]*model.Board) ret1, _ := ret[1].(error) return ret0, ret1 } // GetTemplateBoards indicates an expected call of GetTemplateBoards. -func (mr *MockStoreMockRecorder) GetTemplateBoards(arg0 interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetTemplateBoards(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateBoards", reflect.TypeOf((*MockStore)(nil).GetTemplateBoards), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateBoards", reflect.TypeOf((*MockStore)(nil).GetTemplateBoards), arg0, arg1) } // GetUserByEmail mocks base method. diff --git a/server/services/store/sqlstore/public_methods.go b/server/services/store/sqlstore/public_methods.go index 0a334f7d1..a3f30591b 100644 --- a/server/services/store/sqlstore/public_methods.go +++ b/server/services/store/sqlstore/public_methods.go @@ -435,8 +435,8 @@ func (s *SQLStore) GetTeamsForUser(userID string) ([]*model.Team, error) { } -func (s *SQLStore) GetTemplateBoards(teamID string) ([]*model.Board, error) { - return s.getTemplateBoards(s.db, teamID) +func (s *SQLStore) GetTemplateBoards(teamID string, userID string) ([]*model.Board, error) { + return s.getTemplateBoards(s.db, teamID, userID) } diff --git a/server/services/store/sqlstore/templates.go b/server/services/store/sqlstore/templates.go index a58ba15b4..f23b0fa4c 100644 --- a/server/services/store/sqlstore/templates.go +++ b/server/services/store/sqlstore/templates.go @@ -54,13 +54,24 @@ func (s *SQLStore) removeDefaultTemplates(db sq.BaseRunner, boards []*model.Boar return nil } -// getDefaultTemplateBoards fetches all template blocks . -func (s *SQLStore) getTemplateBoards(db sq.BaseRunner, teamID string) ([]*model.Board, error) { +// getTemplateBoards fetches all template boards . +func (s *SQLStore) getTemplateBoards(db sq.BaseRunner, teamID, userID string) ([]*model.Board, error) { query := s.getQueryBuilder(db). Select(boardFields("")...). - From(s.tablePrefix + "boards"). - Where(sq.Eq{"coalesce(team_id, '0')": teamID}). - Where(sq.Eq{"is_template": true}) + From(s.tablePrefix+"boards as b"). + LeftJoin(s.tablePrefix+"board_members as bm on b.id = bm.board_id and bm.user_id = ?", userID). + Where(sq.Eq{"is_template": true}). + Where(sq.Eq{"b.team_id": teamID}). + Where(sq.Or{ + // this is to include public templates even if there is not board_member entry + sq.And{ + sq.Eq{"bm.board_id": nil}, + sq.Eq{"b.type": model.BoardTypeOpen}, + }, + sq.And{ + sq.NotEq{"bm.board_id": nil}, + }, + }) rows, err := query.Query() if err != nil { @@ -69,5 +80,10 @@ func (s *SQLStore) getTemplateBoards(db sq.BaseRunner, teamID string) ([]*model. } defer s.CloseRows(rows) - return s.boardsFromRows(rows) + userTemplates, err := s.boardsFromRows(rows) + if err != nil { + return nil, err + } + + return userTemplates, nil } diff --git a/server/services/store/store.go b/server/services/store/store.go index 1dce5b066..666a4c32f 100644 --- a/server/services/store/store.go +++ b/server/services/store/store.go @@ -129,7 +129,7 @@ type Store interface { GetNextNotificationHint(remove bool) (*model.NotificationHint, error) RemoveDefaultTemplates(boards []*model.Board) error - GetTemplateBoards(teamID string) ([]*model.Board, error) + GetTemplateBoards(teamID, userID string) ([]*model.Board, error) DBType() string diff --git a/webapp/cypress/integration/loginActions.ts b/webapp/cypress/integration/loginActions.ts index 34c05578c..7d5c463ca 100644 --- a/webapp/cypress/integration/loginActions.ts +++ b/webapp/cypress/integration/loginActions.ts @@ -8,10 +8,8 @@ describe('Login actions', () => { it('Can perform login/register actions', () => { // Redirects to login page - cy.log('**Redirects to error then login page**') + cy.log('**Redirects to login page (except plugin mode) **') cy.visit('/') - cy.location('pathname').should('eq', '/error') - cy.get('button').contains('Log in').click() cy.location('pathname').should('eq', '/login') cy.get('.LoginPage').contains('Log in') cy.get('#login-username').should('exist') @@ -40,7 +38,7 @@ describe('Login actions', () => { // User should not be logged in automatically cy.log('**User should not be logged in automatically**') cy.visit('/') - cy.location('pathname').should('eq', '/error') + cy.location('pathname').should('eq', '/login') // Can log in registered user cy.log('**Can log in registered user**') diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 844f6858b..072a4bd73 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -181,10 +181,13 @@ "ShareBoard.tokenRegenrated": "Token regenerated", "ShareBoard.userPermissionsRemoveMemberText": "Remove member", "ShareBoard.userPermissionsYouText": "(You)", + "ShareTemplate.Title": "Share Template", "Sidebar.about": "About Focalboard", "Sidebar.add-board": "+ Add board", "Sidebar.changePassword": "Change password", "Sidebar.delete-board": "Delete board", + "Sidebar.duplicate-board": "Duplicate board", + "Sidebar.template-from-board": "New template from board", "Sidebar.export-archive": "Export archive", "Sidebar.import": "Import", "Sidebar.import-archive": "Import archive", diff --git a/webapp/i18n/he.json b/webapp/i18n/he.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/webapp/i18n/he.json @@ -0,0 +1 @@ +{} diff --git a/webapp/i18n/nl.json b/webapp/i18n/nl.json index 50fcbd821..e91934efa 100644 --- a/webapp/i18n/nl.json +++ b/webapp/i18n/nl.json @@ -127,6 +127,8 @@ "FindBoFindBoardsDialog.IntroText": "Zoeken naar borden", "FindBoardsDialog.NoResultsFor": "Geen resultaten voor \"{searchQuery}\"", "FindBoardsDialog.NoResultsSubtext": "Controleer de spelling of probeer een andere zoekopdracht.", + "FindBoardsDialog.SubTitle": "Typ om een bord te vinden. Gebruik UP/DOWN om te bladeren. ENTER om te selecteren, ESC om te annuleren", + "FindBoardsDialog.Title": "Boards vinden", "GalleryCard.copiedLink": "Gekopieerd!", "GalleryCard.copyLink": "Kopieer link", "GalleryCard.delete": "Verwijderen", @@ -189,7 +191,10 @@ "ShareBoard.copiedLink": "Gekopieerd!", "ShareBoard.copyLink": "Link kopiëren", "ShareBoard.regenerate": "Token opnieuw genereren", + "ShareBoard.teamPermissionsText": "Iedereen van team {teamName}", "ShareBoard.tokenRegenrated": "Token opnieuw gegenereerd", + "ShareBoard.userPermissionsRemoveMemberText": "Lid verwijderen", + "ShareBoard.userPermissionsYouText": "(jij)", "Sidebar.about": "Over Focalboard", "Sidebar.add-board": "+ Bord toevoegen", "Sidebar.changePassword": "Wachtwoord wijzigen", @@ -199,11 +204,18 @@ "Sidebar.import-archive": "Archief importeren", "Sidebar.invite-users": "Gebruikers uitnodigen", "Sidebar.logout": "Afmelden", + "Sidebar.no-boards-in-category": "Geen boards hier", "Sidebar.random-icons": "Willekeurige iconen", "Sidebar.set-language": "Taal instellen", "Sidebar.set-theme": "Thema instellen", "Sidebar.settings": "Instellingen", "Sidebar.untitled-board": "(Titelloze bord )", + "SidebarCategories.BlocksMenu.Move": "Verplaatsen naar...", + "SidebarCategories.CategoryMenu.CreateNew": "Maak een nieuwe categorie", + "SidebarCategories.CategoryMenu.Delete": "Categorie verwijderen", + "SidebarCategories.CategoryMenu.DeleteModal.Body": "Borden in {categoryName} zullen terug verhuizen naar de Boards categorieën. Je zal niet verwijderd worden uit enig board.", + "SidebarCategories.CategoryMenu.DeleteModal.Title": "Deze categorie verwijderen?", + "SidebarCategories.CategoryMenu.Update": "Categorie hernoemen", "TableComponent.add-icon": "Pictogram toevoegen", "TableComponent.name": "Naam", "TableComponent.plus-new": "+ Nieuw", @@ -260,7 +272,7 @@ "WelcomePage.Description": "Boards is een projectmanagementtool die helpt bij het definiëren, organiseren, volgen en beheren van werk door teams heen, met behulp van een bekende kanban-bordweergave", "WelcomePage.Explore.Button": "Start een rondleiding", "WelcomePage.Heading": "Welkom bij Boards", - "WelcomePage.NoThanks.Text": "Nee bedankt, ik zoek het zelf wel uit.", + "WelcomePage.NoThanks.Text": "Nee bedankt, ik zoek het zelf wel uit", "Workspace.editing-board-template": "Je bent een bordsjabloon aan het bewerken.", "calendar.month": "Maand", "calendar.today": "VANDAAG", @@ -281,6 +293,7 @@ "login.register-button": "of maak een account aan als je er nog geen hebt", "register.login-button": "of meldt je aan als je al een account hebt", "register.signup-title": "Maak een nieuw account", + "shareBoard.lastAdmin": "Besturen moeten ten minste één beheerder hebben", "tutorial_tip.finish_tour": "Klaar", "tutorial_tip.got_it": "Begrepen", "tutorial_tip.ok": "Volgende", diff --git a/webapp/i18n/sv.json b/webapp/i18n/sv.json index 1d552c64c..1ebe4f721 100644 --- a/webapp/i18n/sv.json +++ b/webapp/i18n/sv.json @@ -103,7 +103,7 @@ "KanbanCard.copiedLink": "Kopierad!", "KanbanCard.copyLink": "Kopiera länk", "KanbanCard.delete": "Radera", - "KanbanCard.duplicate": "Radera", + "KanbanCard.duplicate": "Kopiera", "KanbanCard.untitled": "Saknar titel", "Mutator.new-card-from-template": "nytt kort från mall", "Mutator.new-template-from-card": "ny mall från kort", @@ -201,7 +201,7 @@ "ViewTitle.show-description": "visa beskrivning", "ViewTitle.untitled-board": "Tavla saknar titel", "WelcomePage.Description": "Anslagstavlan är ett projekthanteringsverktyg som hjälper till att definiera, organisera, spåra och hantera arbete mellan team med hjälp av en välbekant Kanban-vy", - "WelcomePage.Explore.Button": "Utforska", + "WelcomePage.Explore.Button": "Starta en rundtur", "WelcomePage.Heading": "Välkommen till Anslagstavlan", "Workspace.editing-board-template": "Du redigerar en tavelmall.", "calendar.month": "Månad", diff --git a/webapp/src/components/__snapshots__/centerPanel.test.tsx.snap b/webapp/src/components/__snapshots__/centerPanel.test.tsx.snap index d1c66a10e..044f8c043 100644 --- a/webapp/src/components/__snapshots__/centerPanel.test.tsx.snap +++ b/webapp/src/components/__snapshots__/centerPanel.test.tsx.snap @@ -695,7 +695,20 @@ exports[`components/centerPanel return centerPanel and press touch 1 with readon
+ > +
+ +
+
+ > +
+ +
+
+ > +
+ +
+
{ const editIcon = screen.getByText(template1Title).parentElement?.querySelector('.EditIcon') expect(editIcon).not.toBeNull() userEvent.click(editIcon!) - expect(history.push).toBeCalledTimes(1) }) test('return BoardTemplateSelector and click to add board from template', async () => { render(wrapDNDIntl( diff --git a/webapp/src/components/boardTemplateSelector/boardTemplateSelector.tsx b/webapp/src/components/boardTemplateSelector/boardTemplateSelector.tsx index ec7186c9e..92354d906 100644 --- a/webapp/src/components/boardTemplateSelector/boardTemplateSelector.tsx +++ b/webapp/src/components/boardTemplateSelector/boardTemplateSelector.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useEffect, useState, useCallback, useMemo} from 'react' import {FormattedMessage, useIntl} from 'react-intl' -import {generatePath, useHistory, useRouteMatch} from 'react-router-dom' +import {useHistory, useRouteMatch} from 'react-router-dom' import {Board} from '../../blocks/board' import IconButton from '../../widgets/buttons/iconButton' @@ -23,6 +23,10 @@ import {IUser, UserConfigPatch, UserPropPrefix} from '../../user' import {getMe, patchProps} from '../../store/users' import {BaseTourSteps, TOUR_BASE} from '../onboardingTour' +import {Utils} from "../../utils" + +import {Constants} from "../../constants" + import BoardTemplateSelectorPreview from './boardTemplateSelectorPreview' import BoardTemplateSelectorItem from './boardTemplateSelectorItem' @@ -44,17 +48,14 @@ const BoardTemplateSelector = (props: Props) => { const me = useAppSelector(getMe) const showBoard = useCallback(async (boardId) => { - const params = {...match.params, boardId: boardId || ''} - delete params.viewId - const newPath = generatePath(match.path, params) - history.push(newPath) + Utils.showBoard(boardId, match, history) if (onClose) { onClose() } }, [match, history, onClose]) useEffect(() => { - if (octoClient.teamId !== '0' && globalTemplates.length === 0) { + if (octoClient.teamId !== Constants.globalTeamId && globalTemplates.length === 0) { dispatch(fetchGlobalTemplates()) } }, [octoClient.teamId]) @@ -96,7 +97,7 @@ const BoardTemplateSelector = (props: Props) => { } const handleUseTemplate = async () => { - await mutator.addBoardFromTemplate(currentTeam?.id || '0', intl, showBoard, () => showBoard(currentBoardId), activeTemplate.id, currentTeam?.id) + await mutator.addBoardFromTemplate(currentTeam?.id || Constants.globalTeamId, intl, showBoard, () => showBoard(currentBoardId), activeTemplate.id, currentTeam?.id) if (activeTemplate.title === OnboardingBoardTitle) { resetTour() } diff --git a/webapp/src/components/boardTemplateSelector/boardTemplateSelectorItem.tsx b/webapp/src/components/boardTemplateSelector/boardTemplateSelectorItem.tsx index 9c7308857..f1aa5a0df 100644 --- a/webapp/src/components/boardTemplateSelector/boardTemplateSelectorItem.tsx +++ b/webapp/src/components/boardTemplateSelector/boardTemplateSelectorItem.tsx @@ -10,6 +10,7 @@ import EditIcon from '../../widgets/icons/edit' import DeleteBoardDialog from '../sidebar/deleteBoardDialog' import './boardTemplateSelectorItem.scss' +import {Constants} from "../../constants" type Props = { isActive: boolean @@ -38,7 +39,9 @@ const BoardTemplateSelectorItem = (props: Props) => { > {template.icon} {template.title} - {!template.templateVersion && + + {/* don't show template menu options for default templates */} + {template.teamId !== Constants.globalTeamId &&
} diff --git a/webapp/src/components/centerPanel.tsx b/webapp/src/components/centerPanel.tsx index f67f9cb00..e15affe41 100644 --- a/webapp/src/components/centerPanel.tsx +++ b/webapp/src/components/centerPanel.tsx @@ -38,6 +38,7 @@ import {UserConfigPatch} from '../user' import octoClient from '../octoClient' import ShareBoardButton from './shareBoard/shareBoardButton' +import ShareBoardLoginButton from './shareBoard/shareBoardLoginButton' import CardDialog from './cardDialog' import RootPortal from './rootPortal' @@ -334,6 +335,9 @@ const CenterPanel = (props: Props) => { e.stopPropagation() }, [selectedCardIds, props.activeView, props.cards, showCard]) + const showShareButton = !props.readonly && me?.id !== 'single-user' + const showShareLoginButton = props.readonly && me?.id !== 'single-user' + const {groupByProperty, activeView, board, views, cards} = props const {visible: visibleGroups, hidden: hiddenGroups} = useMemo( () => getVisibleAndHiddenGroups(cards, activeView.fields.visibleOptionIds, activeView.fields.hiddenOptionIds, groupByProperty), @@ -369,13 +373,14 @@ const CenterPanel = (props: Props) => { readonly={props.readonly} />
- {!props.readonly && - ( - - ) + {showShareButton && + + } + {showShareLoginButton && + }
diff --git a/webapp/src/components/shareBoard/__snapshots__/shareBoardLoginButton.test.tsx.snap b/webapp/src/components/shareBoard/__snapshots__/shareBoardLoginButton.test.tsx.snap new file mode 100644 index 000000000..f7995b53d --- /dev/null +++ b/webapp/src/components/shareBoard/__snapshots__/shareBoardLoginButton.test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`src/components/shareBoard/shareBoardLoginButton should match snapshot 1`] = ` +
+
+ +
+
+`; diff --git a/webapp/src/components/shareBoard/shareBoard.scss b/webapp/src/components/shareBoard/shareBoard.scss index 325752e87..692864107 100644 --- a/webapp/src/components/shareBoard/shareBoard.scss +++ b/webapp/src/components/shareBoard/shareBoard.scss @@ -212,5 +212,16 @@ text-decoration: underline; } } + + .Menu { + position: fixed; + left: 55%; + right: calc(45% - 240px); + + .menu-contents { + min-width: 240px; + max-width: 240px; + } + } } } diff --git a/webapp/src/components/shareBoard/shareBoard.tsx b/webapp/src/components/shareBoard/shareBoard.tsx index c3d89ef02..76dfc35a4 100644 --- a/webapp/src/components/shareBoard/shareBoard.tsx +++ b/webapp/src/components/shareBoard/shareBoard.tsx @@ -9,7 +9,7 @@ import Select from 'react-select/async' import {CSSObject} from '@emotion/serialize' import {useAppSelector} from '../../store/hooks' -import {getCurrentBoardId, getCurrentBoardMembers} from '../../store/boards' +import {getCurrentBoard, getCurrentBoardMembers} from '../../store/boards' import {getMe, getBoardUsersList} from '../../store/users' import {Utils, IDType} from '../../utils' @@ -95,7 +95,8 @@ export default function ShareBoardDialog(props: Props): JSX.Element { // members of the current board const members = useAppSelector<{[key: string]: BoardMember}>(getCurrentBoardMembers) - const boardId = useAppSelector(getCurrentBoardId) + const board = useAppSelector(getCurrentBoard) + const boardId = board.id const boardUsers = useAppSelector(getBoardUsersList) const me = useAppSelector(getMe) @@ -239,7 +240,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element { )) } - const toolbar = ( + const shareBoardTitle = ( ) + const shareTemplateTitle = ( + + + + ) + + const toolbar = board.isTemplate ? shareTemplateTitle : shareBoardTitle + return ( - {props.enableSharedBoards && ( + {props.enableSharedBoards && !board.isTemplate && (
)} - {(props.enableSharedBoards && publish) && + {(props.enableSharedBoards && publish && !board.isTemplate) && (
@@ -401,7 +413,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element { )} - {!publish && ( + {!publish && !board.isTemplate && (
diff --git a/webapp/src/components/shareBoard/shareBoardLoginButton.scss b/webapp/src/components/shareBoard/shareBoardLoginButton.scss new file mode 100644 index 000000000..b08f71489 --- /dev/null +++ b/webapp/src/components/shareBoard/shareBoardLoginButton.scss @@ -0,0 +1,4 @@ +.ShareBoardLoginButton { + margin-top: 38px; +} + diff --git a/webapp/src/components/shareBoard/shareBoardLoginButton.test.tsx b/webapp/src/components/shareBoard/shareBoardLoginButton.test.tsx new file mode 100644 index 000000000..55a5e2046 --- /dev/null +++ b/webapp/src/components/shareBoard/shareBoardLoginButton.test.tsx @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {render} from '@testing-library/react' +import React from 'react' + +import {TestBlockFactory} from '../../test/testBlockFactory' +import {wrapDNDIntl} from '../../testUtils' + +import ShareBoardLoginButton from './shareBoardLoginButton' +jest.useFakeTimers() + +const boardId = '1' + +const board = TestBlockFactory.createBoard() +board.id = boardId + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom') + + return { + ...originalModule, + useRouteMatch: jest.fn(() => { + return { + teamId: 'team1', + boardId: 'boardId1', + viewId: 'viewId1', + cardId: 'cardId1', + } + }), + } +}) + +describe('src/components/shareBoard/shareBoardLoginButton', () => { + const savedLocation = window.location + + afterEach(() => { + window.location = savedLocation + }) + + test('should match snapshot', async () => { + // delete window.location + window.location = Object.assign(new URL('https://example.org/mattermost')) + const result = render( + wrapDNDIntl( + , + )) + const renderer = result.container + + expect(renderer).toMatchSnapshot() + }) +}) diff --git a/webapp/src/components/shareBoard/shareBoardLoginButton.tsx b/webapp/src/components/shareBoard/shareBoardLoginButton.tsx new file mode 100644 index 000000000..0b3fbf4c9 --- /dev/null +++ b/webapp/src/components/shareBoard/shareBoardLoginButton.tsx @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react' +import {FormattedMessage} from 'react-intl' +import {generatePath, useRouteMatch, useHistory} from 'react-router-dom' + +import Button from '../../widgets/buttons/button' +import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../telemetry/telemetryClient' +import {Utils} from '../../utils' + +import './shareBoardLoginButton.scss' + +const ShareBoardLoginButton = () => { + const match = useRouteMatch<{teamId: string, boardId: string, viewId?: string, cardId?: string}>() + const history = useHistory() + + let redirectQueryParam = 'r=' + encodeURIComponent(generatePath('/:boardId?/:viewId?/:cardId?', match.params)) + if (Utils.isFocalboardLegacy()) { + redirectQueryParam = 'redirect_to=' + encodeURIComponent(generatePath('/boards/team/:teamId/:boardId?/:viewId?/:cardId?', match.params)) + } + const loginPath = '/login?' + redirectQueryParam + + const onLoginClick = useCallback(() => { + TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ShareBoardLogin) + if (Utils.isFocalboardLegacy()) { + location.assign(loginPath) + } else { + history.push(loginPath) + } + + }, []) + + return ( +
+ +
+ ) +} + +export default React.memo(ShareBoardLoginButton) diff --git a/webapp/src/components/sidebar/sidebar.tsx b/webapp/src/components/sidebar/sidebar.tsx index 596a96561..ba7efb4b2 100644 --- a/webapp/src/components/sidebar/sidebar.tsx +++ b/webapp/src/components/sidebar/sidebar.tsx @@ -29,6 +29,8 @@ import wsClient, {WSClient} from '../../wsclient' import {getCurrentTeam} from '../../store/teams' +import {Constants} from "../../constants" + import SidebarCategory from './sidebarCategory' import SidebarSettingsMenu from './sidebarSettingsMenu' import SidebarUserMenu from './sidebarUserMenu' @@ -152,7 +154,7 @@ const Sidebar = (props: Props) => {
} - {team && team.id !== '0' && + {team && team.id !== Constants.globalTeamId &&
{Utils.isFocalboardPlugin() && <> diff --git a/webapp/src/components/sidebar/sidebarBoardItem.tsx b/webapp/src/components/sidebar/sidebarBoardItem.tsx index bea0b48df..43a4712b8 100644 --- a/webapp/src/components/sidebar/sidebarBoardItem.tsx +++ b/webapp/src/components/sidebar/sidebarBoardItem.tsx @@ -1,7 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useState} from 'react' +import React, {useCallback, useState} from 'react' import {useIntl} from 'react-intl' +import {useHistory, useRouteMatch} from "react-router-dom" import {Board} from '../../blocks/board' import {BoardView, IViewType} from '../../blocks/boardView' @@ -27,6 +28,10 @@ import CalendarIcon from '../../widgets/icons/calendar' import {getCurrentTeam} from '../../store/teams' import {Permission} from '../../constants' +import DuplicateIcon from "../../widgets/icons/duplicate" +import {Utils} from "../../utils" + +import AddIcon from "../../widgets/icons/add" const iconForViewType = (viewType: IViewType): JSX.Element => { switch (viewType) { @@ -58,6 +63,9 @@ const SidebarBoardItem = (props: Props) => { const currentViewId = useAppSelector(getCurrentViewId) const teamID = team?.id || '' + const match = useRouteMatch<{boardId: string, viewId?: string, cardId?: string, teamId?: string}>() + const history = useHistory() + const generateMoveToCategoryOptions = (blockID: string) => { return props.allCategories.map((category) => ( { } const board = props.board + + const handleDuplicateBoard = useCallback(async(asTemplate: boolean) => { + const blocksAndBoards = await mutator.duplicateBoard( + board.id, + undefined, + asTemplate, + undefined, + () => { + Utils.showBoard(board.id, match, history) + return Promise.resolve() + } + ) + + if (blocksAndBoards.boards.length === 0) { + return + } + + const boardId = blocksAndBoards.boards[0].id + Utils.showBoard(boardId, match, history) + + }, [board.id]) + const title = board.title || intl.formatMessage({id: 'Sidebar.untitled-board', defaultMessage: '(Untitled Board)'}) return ( <> @@ -129,6 +159,18 @@ const SidebarBoardItem = (props: Props) => { > {generateMoveToCategoryOptions(board.id)} + } + onClick={() => handleDuplicateBoard(board.isTemplate)} + /> + } + onClick={() => handleDuplicateBoard(true)} + />
diff --git a/webapp/src/components/sidebar/sidebarCategory.tsx b/webapp/src/components/sidebar/sidebarCategory.tsx index cb068f361..eb4567dc1 100644 --- a/webapp/src/components/sidebar/sidebarCategory.tsx +++ b/webapp/src/components/sidebar/sidebarCategory.tsx @@ -60,15 +60,7 @@ const SidebarCategory = (props: Props) => { const teamID = team?.id || '' const showBoard = useCallback((boardId) => { - // if the same board, reuse the match params - // otherwise remove viewId and cardId, results in first view being selected - const params = {...match.params, boardId: boardId || ''} - if (boardId !== match.params.boardId) { - params.viewId = undefined - params.cardId = undefined - } - const newPath = generatePath(match.path, params) - history.push(newPath) + Utils.showBoard(boardId, match, history) props.hideSidebar() }, [match, history]) diff --git a/webapp/src/components/viewHeader/__snapshots__/viewHeaderActionsMenu.test.tsx.snap b/webapp/src/components/viewHeader/__snapshots__/viewHeaderActionsMenu.test.tsx.snap index 20d3c905f..c98420199 100644 --- a/webapp/src/components/viewHeader/__snapshots__/viewHeaderActionsMenu.test.tsx.snap +++ b/webapp/src/components/viewHeader/__snapshots__/viewHeaderActionsMenu.test.tsx.snap @@ -19,7 +19,7 @@ exports[`components/viewHeader/viewHeaderActionsMenu return menu 1`] = ` />