Merge branch 'main' into private-onboarding-board

This commit is contained in:
Mattermod 2022-04-04 18:43:24 +03:00 committed by GitHub
commit e1dd866fba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 754 additions and 615 deletions

View file

@ -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() {

View file

@ -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'

View file

@ -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<number> {
// 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[]) {

View file

@ -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<number> {
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() {

View file

@ -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() {

View file

@ -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<Section> {

View file

@ -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() {

View file

@ -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',

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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)
}

View file

@ -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) {

View file

@ -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

69
server/app/import_test.go Normal file
View file

@ -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"}}
`

View file

@ -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
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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")

View file

@ -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()

View file

@ -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))

View file

@ -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)
})
}

View file

@ -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

View file

@ -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)
}

View file

@ -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)
}
}
}

View file

@ -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()
}

View file

@ -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.

View file

@ -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)
}

View file

@ -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
}

View file

@ -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

View file

@ -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**')

View file

@ -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",

1
webapp/i18n/he.json Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -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 <b>UP/DOWN</b> om te bladeren. <b>ENTER</b> om te selecteren, <b>ESC</b> 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 <b>{categoryName}</b> 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",

View file

@ -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",

View file

@ -695,7 +695,20 @@ exports[`components/centerPanel return centerPanel and press touch 1 with readon
</div>
<div
class="shareButtonWrapper"
/>
>
<div
class="ShareBoardLoginButton"
>
<button
title="Login"
type="button"
>
<span>
Login
</span>
</button>
</div>
</div>
</div>
<div
class="ViewHeader"

View file

@ -970,7 +970,20 @@ exports[`src/components/workspace return workspace readonly and showcard 1`] = `
</div>
<div
class="shareButtonWrapper"
/>
>
<div
class="ShareBoardLoginButton"
>
<button
title="Login"
type="button"
>
<span>
Login
</span>
</button>
</div>
</div>
</div>
<div
class="ViewHeader"
@ -2174,7 +2187,20 @@ exports[`src/components/workspace should match snapshot with readonly 1`] = `
</div>
<div
class="shareButtonWrapper"
/>
>
<div
class="ShareBoardLoginButton"
>
<button
title="Login"
type="button"
>
<span>
Login
</span>
</button>
</div>
</div>
</div>
<div
class="ViewHeader"

View file

@ -231,7 +231,6 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => {
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(

View file

@ -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<IUser|null>(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()
}

View file

@ -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) => {
>
<span className='template-icon'>{template.icon}</span>
<span className='template-name'>{template.title}</span>
{!template.templateVersion &&
{/* don't show template menu options for default templates */}
{template.teamId !== Constants.globalTeamId &&
<div className='actions'>
<IconButton
icon={<DeleteIcon/>}

View file

@ -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}
/>
<div className='shareButtonWrapper'>
{!props.readonly &&
(
<ShareBoardButton
boardId={props.board.id}
enableSharedBoards={props.clientConfig?.enablePublicSharedBoards || false}
/>
)
{showShareButton &&
<ShareBoardButton
boardId={props.board.id}
enableSharedBoards={props.clientConfig?.enablePublicSharedBoards || false}
/>
}
{showShareLoginButton &&
<ShareBoardLoginButton/>
}
<ShareBoardTourStep/>
</div>

View file

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`src/components/shareBoard/shareBoardLoginButton should match snapshot 1`] = `
<div>
<div
class="ShareBoardLoginButton"
>
<button
class="Button emphasis--primary size--medium"
title="Login"
type="button"
>
<span>
Login
</span>
</button>
</div>
</div>
`;

View file

@ -212,5 +212,16 @@
text-decoration: underline;
}
}
.Menu {
position: fixed;
left: 55%;
right: calc(45% - 240px);
.menu-contents {
min-width: 240px;
max-width: 240px;
}
}
}
}

View file

@ -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<IUser[]>(getBoardUsersList)
const me = useAppSelector<IUser|null>(getMe)
@ -239,7 +240,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
))
}
const toolbar = (
const shareBoardTitle = (
<span className='text-heading5'>
<FormattedMessage
id={'ShareBoard.Title'}
@ -248,6 +249,17 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
</span>
)
const shareTemplateTitle = (
<span className='text-heading5'>
<FormattedMessage
id={'ShareTemplate.Title'}
defaultMessage={'Share Template'}
/>
</span>
)
const toolbar = board.isTemplate ? shareTemplateTitle : shareBoardTitle
return (
<Dialog
onClose={props.onClose}
@ -299,7 +311,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
})}
</div>
{props.enableSharedBoards && (
{props.enableSharedBoards && !board.isTemplate && (
<div className='tabs-container'>
<button
onClick={() => setPublish(false)}
@ -323,7 +335,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
</BoardPermissionGate>
</div>
)}
{(props.enableSharedBoards && publish) &&
{(props.enableSharedBoards && publish && !board.isTemplate) &&
(<BoardPermissionGate permissions={[Permission.ShareBoard]}>
<div className='tabs-content'>
<div>
@ -401,7 +413,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
</BoardPermissionGate>
)}
{!publish && (
{!publish && !board.isTemplate && (
<div className='tabs-content'>
<div>
<div className='d-flex justify-content-between'>

View file

@ -0,0 +1,4 @@
.ShareBoardLoginButton {
margin-top: 38px;
}

View file

@ -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(
<ShareBoardLoginButton/>,
))
const renderer = result.container
expect(renderer).toMatchSnapshot()
})
})

View file

@ -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 (
<div className='ShareBoardLoginButton'>
<Button
title='Login'
size='medium'
emphasis='primary'
onClick={() => onLoginClick()}
>
<FormattedMessage
id='CenterPanel.Login'
defaultMessage='Login'
/>
</Button>
</div>
)
}
export default React.memo(ShareBoardLoginButton)

View file

@ -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) => {
</div>
</div>}
{team && team.id !== '0' &&
{team && team.id !== Constants.globalTeamId &&
<div className='WorkspaceTitle'>
{Utils.isFocalboardPlugin() &&
<>

View file

@ -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) => (
<Menu.Text
@ -74,6 +82,28 @@ const SidebarBoardItem = (props: Props) => {
}
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)}
</Menu.SubMenu>
<Menu.Text
id='duplicateBoard'
name={intl.formatMessage({id: 'Sidebar.duplicate-board', defaultMessage: 'Duplicate board'})}
icon={<DuplicateIcon/>}
onClick={() => handleDuplicateBoard(board.isTemplate)}
/>
<Menu.Text
id='templateFromBoard'
name={intl.formatMessage({id: 'Sidebar.template-from-board', defaultMessage: 'New template from board'})}
icon={<AddIcon/>}
onClick={() => handleDuplicateBoard(true)}
/>
</Menu>
</MenuWrapper>
</div>

View file

@ -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])

View file

@ -19,7 +19,7 @@ exports[`components/viewHeader/viewHeaderActionsMenu return menu 1`] = `
/>
</button>
<div
class="Menu noselect bottom "
class="Menu noselect left "
>
<div
class="menu-contents"
@ -116,7 +116,7 @@ exports[`components/viewHeader/viewHeaderActionsMenu return menu and verify call
/>
</button>
<div
class="Menu noselect bottom "
class="Menu noselect left "
>
<div
class="menu-contents"
@ -213,7 +213,7 @@ exports[`components/viewHeader/viewHeaderActionsMenu return menu and verify call
/>
</button>
<div
class="Menu noselect bottom "
class="Menu noselect left "
>
<div
class="menu-contents"

View file

@ -100,7 +100,7 @@ const ViewHeaderActionsMenu = (props: Props) => {
<ModalWrapper>
<MenuWrapper label={intl.formatMessage({id: 'ViewHeader.view-header-menu', defaultMessage: 'View header menu'})}>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu position='left'>
<Menu.Text
id='exportCsv'
name={intl.formatMessage({id: 'ViewHeader.export-csv', defaultMessage: 'Export to CSV'})}

View file

@ -17,6 +17,8 @@
background-color: rgba(230, 220, 192, 0.9);
text-align: center;
padding: 10px;
color: rgb(63, 67, 80);
font-weight: bold;
}
}
}

View file

@ -186,6 +186,8 @@ class Constants {
Y: ['y', 89],
Z: ['z', 90],
}
static readonly globalTeamId = '0'
}
export {Constants, Permission}

View file

@ -1014,7 +1014,7 @@ class Mutator {
afterRedo?: (newBoardId: string) => Promise<void>,
beforeUndo?: () => Promise<void>,
toTeam?: string,
): Promise<[Block[], string]> {
): Promise<BoardsAndBlocks> {
return undoManager.perform(
async () => {
const boardsAndBlocks = await octoClient.duplicateBoard(boardId, asTemplate, toTeam)
@ -1047,7 +1047,7 @@ class Mutator {
beforeUndo: () => Promise<void>,
boardTemplateId: string,
toTeam?: string,
): Promise<[Block[], string]> {
): Promise<BoardsAndBlocks> {
const asTemplate = false
const actionDescription = intl.formatMessage({id: 'Mutator.new-board-from-template', defaultMessage: 'new board from template'})

View file

@ -23,10 +23,6 @@ test('OctoClient: get blocks', async () => {
let boards = await octoClient.getBlocksWithType('card')
expect(boards.length).toBe(blocks.length)
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify(blocks)))
boards = await octoClient.getSubtree()
expect(boards.length).toBe(blocks.length)
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify(blocks)))
const response = await octoClient.exportArchive()
expect(response.status).toBe(200)

View file

@ -12,6 +12,7 @@ import {Category, CategoryBlocks} from './store/sidebar'
import {Team} from './store/teams'
import {Subscription} from './wsclient'
import {PrepareOnboardingResponse} from './onboardingTour'
import {Constants} from "./constants"
//
// OctoClient is the client interface to the server APIs
@ -45,7 +46,7 @@ class OctoClient {
localStorage.setItem('focalboardSessionId', value)
}
constructor(serverUrl?: string, public teamId = '0') {
constructor(serverUrl?: string, public teamId = Constants.globalTeamId) {
this.serverUrl = serverUrl
}
@ -144,7 +145,7 @@ class OctoClient {
private teamPath(teamId?: string): string {
let teamIdToUse = teamId
if (!teamId) {
teamIdToUse = this.teamId === '0' ? UserSettings.lastTeamId || this.teamId : this.teamId
teamIdToUse = this.teamId === Constants.globalTeamId ? UserSettings.lastTeamId || this.teamId : this.teamId
}
return `/api/v1/teams/${teamIdToUse}`
@ -200,20 +201,6 @@ class OctoClient {
return (await this.getJson(response, {})) as Record<string, string>
}
async getSubtree(boardId?: string, levels = 2, teamID?: string): Promise<Block[]> {
let path = this.teamPath(teamID) + `/blocks/${encodeURIComponent(boardId || '')}/subtree?l=${levels}`
const readToken = Utils.getReadToken()
if (readToken) {
path += `&read_token=${readToken}`
}
const response = await fetch(this.getBaseURL() + path, {headers: this.headers()})
if (response.status !== 200) {
return []
}
const blocks = (await this.getJson(response, [])) as Block[]
return this.fixBlocks(blocks)
}
// If no boardID is provided, it will export the entire archive
async exportArchive(boardID = ''): Promise<Response> {
const path = `/api/v1/boards/${boardID}/archive/export`
@ -349,7 +336,6 @@ class OctoClient {
async followBlock(blockId: string, blockType: string, userId: string): Promise<Response> {
const body: Subscription = {
teamId: this.teamId,
blockType,
blockId,
subscriberType: 'user',

View file

@ -22,6 +22,8 @@ import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../teleme
import {fetchUserBlockSubscriptions, getMe} from '../../store/users'
import {IUser} from '../../user'
import {Constants} from "../../constants"
import SetWindowTitleAndIcon from './setWindowTitleAndIcon'
import TeamToBoardAndViewRedirect from './teamToBoardAndViewRedirect'
import UndoRedoHotKeys from './undoRedoHotKeys'
@ -41,7 +43,7 @@ const BoardPage = (props: Props): JSX.Element => {
const dispatch = useAppDispatch()
const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string, teamId?: string}>()
const [mobileWarningClosed, setMobileWarningClosed] = useState(UserSettings.mobileWarningClosed)
const teamId = match.params.teamId || UserSettings.lastTeamId || '0'
const teamId = match.params.teamId || UserSettings.lastTeamId || Constants.globalTeamId
const me = useAppSelector<IUser|null>(getMe)
// if we're in a legacy route and not showing a shared board,
@ -110,7 +112,7 @@ const BoardPage = (props: Props): JSX.Element => {
// and set it as most recently viewed board
UserSettings.setLastBoardID(teamId, match.params.boardId)
if (match.params.viewId && match.params.viewId !== '0') {
if (match.params.viewId && match.params.viewId !== Constants.globalTeamId) {
dispatch(setCurrentView(match.params.viewId))
UserSettings.setLastViewId(match.params.boardId, match.params.viewId)
}

View file

@ -8,6 +8,7 @@ import {setCurrent as setCurrentView, getCurrentBoardViews} from '../../store/vi
import {useAppSelector, useAppDispatch} from '../../store/hooks'
import {UserSettings} from '../../userSettings'
import {getSidebarCategories} from '../../store/sidebar'
import {Constants} from "../../constants"
const TeamToBoardAndViewRedirect = (): null => {
const boardId = useAppSelector(getCurrentBoardId)
@ -16,7 +17,7 @@ const TeamToBoardAndViewRedirect = (): null => {
const history = useHistory()
const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string, teamId?: string}>()
const categories = useAppSelector(getSidebarCategories)
const teamId = match.params.teamId || UserSettings.lastTeamId || '0'
const teamId = match.params.teamId || UserSettings.lastTeamId || Constants.globalTeamId
useEffect(() => {
let boardID = match.params.boardId

View file

@ -22,6 +22,7 @@ import {useAppSelector, useAppDispatch} from '../../store/hooks'
import {followBlock, getMe, unfollowBlock} from '../../store/users'
import {IUser} from '../../user'
import {Constants} from "../../constants"
const websocketTimeoutForBanner = 5000
@ -49,8 +50,8 @@ const WebsocketConnection = (props: Props) => {
useEffect(() => {
let subscribedToTeam = false
if (wsClient.state === 'open') {
wsClient.authenticate(props.teamId || '0', token)
wsClient.subscribeToTeam(props.teamId || '0')
wsClient.authenticate(props.teamId || Constants.globalTeamId, token)
wsClient.subscribeToTeam(props.teamId || Constants.globalTeamId)
subscribedToTeam = true
}
@ -71,7 +72,7 @@ const WebsocketConnection = (props: Props) => {
const incrementalBoardUpdate = (_: WSClient, boards: Board[]) => {
// only takes into account the entities that belong to the team or the user boards
const teamBoards = boards.filter((b: Board) => b.teamId === '0' || b.teamId === props.teamId)
const teamBoards = boards.filter((b: Board) => b.teamId === Constants.globalTeamId || b.teamId === props.teamId)
dispatch(updateBoards(teamBoards))
}
@ -83,8 +84,8 @@ const WebsocketConnection = (props: Props) => {
const updateWebsocketState = (_: WSClient, newState: 'init'|'open'|'close'): void => {
if (newState === 'open') {
const newToken = localStorage.getItem('focalboardSessionId') || ''
wsClient.authenticate(props.teamId || '0', newToken)
wsClient.subscribeToTeam(props.teamId || '0')
wsClient.authenticate(props.teamId || Constants.globalTeamId, newToken)
wsClient.subscribeToTeam(props.teamId || Constants.globalTeamId)
subscribedToTeam = true
}
@ -108,12 +109,12 @@ const WebsocketConnection = (props: Props) => {
wsClient.addOnReconnect(() => dispatch(props.loadAction(props.boardId)))
wsClient.addOnStateChange(updateWebsocketState)
wsClient.setOnFollowBlock((_: WSClient, subscription: Subscription): void => {
if (subscription.subscriberId === me?.id && subscription.teamId === props.teamId) {
if (subscription.subscriberId === me?.id) {
dispatch(followBlock(subscription))
}
})
wsClient.setOnUnfollowBlock((_: WSClient, subscription: Subscription): void => {
if (subscription.subscriberId === me?.id && subscription.teamId === props.teamId) {
if (subscription.subscriberId === me?.id) {
dispatch(unfollowBlock(subscription))
}
})

View file

@ -10,6 +10,7 @@ import Button from '../widgets/buttons/button'
import './errorPage.scss'
import {errorDefFromId, ErrorId} from '../errors'
import {Utils} from '../utils'
const ErrorPage = () => {
const history = useHistory()
@ -45,6 +46,10 @@ const ErrorPage = () => {
)
})
if (!Utils.isFocalboardPlugin() && errid === ErrorId.NotLoggedIn) {
handleButtonClick(errorDef.button1Redirect)
}
return (
<div className='ErrorPage'>
<div>

View file

@ -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 {useHistory, Link} from 'react-router-dom'
import {Link, useLocation, useHistory} from 'react-router-dom'
import {FormattedMessage} from 'react-intl'
import {useAppDispatch} from '../store/hooks'
@ -15,14 +16,19 @@ const LoginPage = () => {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const history = useHistory()
const dispatch = useAppDispatch()
const queryParams = new URLSearchParams(useLocation().search)
const history = useHistory()
const handleLogin = async (): Promise<void> => {
const logged = await client.login(username, password)
if (logged) {
await dispatch(fetchMe())
history.push('/')
if (queryParams) {
history.push(queryParams.get('r') || '/')
} else {
history.push('/')
}
} else {
setErrorMessage('Login failed')
}

View file

@ -6,6 +6,8 @@ import {createSlice, createAsyncThunk} from '@reduxjs/toolkit'
import {default as client} from '../octoClient'
import {Board} from '../blocks/board'
import {Constants} from "../constants"
import {RootState} from './index'
// ToDo: move this to team templates or simply templates
@ -13,7 +15,7 @@ import {RootState} from './index'
export const fetchGlobalTemplates = createAsyncThunk(
'globalTemplates/fetch',
async () => {
const templates = await client.getTeamTemplates('0')
const templates = await client.getTeamTemplates(Constants.globalTeamId)
return templates.sort((a, b) => a.title.localeCompare(b.title))
},
)

View file

@ -31,6 +31,7 @@ export const TelemetryActions = {
AddTemplateFromCard: 'addTemplateFromCard',
ViewSharedBoard: 'viewSharedBoard',
ShareBoardOpenModal: 'shareBoard_openModal',
ShareBoardLogin: 'shareBoard_login',
ShareLinkPublicCopy: 'shareLinkPublic_copy',
ShareLinkInternalCopy: 'shareLinkInternal_copy',
ImportArchive: 'settings_importArchive',

View file

@ -3,6 +3,10 @@
import {createIntl} from 'react-intl'
import {createMemoryHistory} from "history"
import {match as routerMatch} from "react-router-dom"
import {Utils, IDType} from './utils'
import {IAppWindow} from './types'
@ -161,4 +165,25 @@ describe('utils', () => {
expect(Utils.compareVersions('10.9.4', '10.9.2')).toBe(-1)
})
})
describe('showBoard test', () => {
it('should switch boards', () => {
const match = {
params: {
boardId: 'board_id_1',
viewId: 'view_id_1',
cardId: 'card_id_1',
teamId: 'team_id_1',
},
path: '/team/:teamId/:boardId?/:viewId?/:cardId?',
} as unknown as routerMatch<{boardId: string, viewId?: string, cardId?: string, teamId?: string}>
const history = createMemoryHistory()
history.push = jest.fn()
Utils.showBoard('board_id_2', match, history)
expect(history.push).toBeCalledWith('/team/team_id_1/board_id_2')
})
})
})

View file

@ -4,6 +4,10 @@ import {marked} from 'marked'
import {IntlShape} from 'react-intl'
import moment from 'moment'
import {generatePath, match as routerMatch} from "react-router-dom"
import {History} from "history"
import {Block} from './blocks/block'
import {Board as BoardType, BoardMember, createBoard} from './blocks/board'
import {createBoardView} from './blocks/boardView'
@ -509,7 +513,7 @@ class Utils {
}
static getFrontendBaseURL(absolute?: boolean): string {
let frontendBaseURL = window.frontendBaseURL || Utils.getBaseURL(absolute)
let frontendBaseURL = window.frontendBaseURL || Utils.getBaseURL()
frontendBaseURL = frontendBaseURL.replace(/\/+$/, '')
if (frontendBaseURL.indexOf('/') === 0) {
frontendBaseURL = frontendBaseURL.slice(1)
@ -703,6 +707,22 @@ class Utils {
}
return (Utils.isMac() && e.metaKey) || (!Utils.isMac() && e.ctrlKey && !e.altKey)
}
static showBoard(
boardId: string,
match: routerMatch<{boardId: string, viewId?: string, cardId?: string, teamId?: string}>,
history: History,
) {
// 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)
}
}
export {Utils, IDType}

View file

@ -51,7 +51,6 @@ type WSSubscriptionMsg = {
export interface Subscription {
blockId: string
teamId: string
subscriberId: string
blockType: string
subscriberType: string