Merge branch 'main' into private-onboarding-board
This commit is contained in:
commit
e1dd866fba
68 changed files with 754 additions and 615 deletions
|
@ -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() {
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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[]) {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
69
server/app/import_test.go
Normal 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"}}
|
||||
`
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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**')
|
||||
|
|
|
@ -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
1
webapp/i18n/he.json
Normal file
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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/>}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -212,5 +212,16 @@
|
|||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.Menu {
|
||||
position: fixed;
|
||||
left: 55%;
|
||||
right: calc(45% - 240px);
|
||||
|
||||
.menu-contents {
|
||||
min-width: 240px;
|
||||
max-width: 240px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
.ShareBoardLoginButton {
|
||||
margin-top: 38px;
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
51
webapp/src/components/shareBoard/shareBoardLoginButton.tsx
Normal file
51
webapp/src/components/shareBoard/shareBoardLoginButton.tsx
Normal 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)
|
|
@ -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() &&
|
||||
<>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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])
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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'})}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -186,6 +186,8 @@ class Constants {
|
|||
Y: ['y', 89],
|
||||
Z: ['z', 90],
|
||||
}
|
||||
|
||||
static readonly globalTeamId = '0'
|
||||
}
|
||||
|
||||
export {Constants, Permission}
|
||||
|
|
|
@ -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'})
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -51,7 +51,6 @@ type WSSubscriptionMsg = {
|
|||
|
||||
export interface Subscription {
|
||||
blockId: string
|
||||
teamId: string
|
||||
subscriberId: string
|
||||
blockType: string
|
||||
subscriberType: string
|
||||
|
|
Loading…
Reference in a new issue