Merge branch 'main' into private-onboarding-board

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

View file

@ -5,6 +5,7 @@ import minimist from 'minimist'
import {exit} from 'process' import {exit} from 'process'
import {ArchiveUtils} from '../util/archive' import {ArchiveUtils} from '../util/archive'
import {Block} from '../../webapp/src/blocks/block' import {Block} from '../../webapp/src/blocks/block'
import {Board} from '../../webapp/src/blocks/board'
import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board'
import {createBoardView} from '../../webapp/src/blocks/boardView' import {createBoardView} from '../../webapp/src/blocks/boardView'
import {createCard} from '../../webapp/src/blocks/card' import {createCard} from '../../webapp/src/blocks/card'
@ -49,11 +50,11 @@ function main() {
const input = JSON.parse(inputData) as Asana const input = JSON.parse(inputData) as Asana
// Convert // Convert
const blocks = convert(input) const [boards, blocks] = convert(input)
// Save output // Save output
// TODO: Stream output // TODO: Stream output
const outputData = ArchiveUtils.buildBlockArchive(blocks) const outputData = ArchiveUtils.buildBlockArchive(boards, blocks)
fs.writeFileSync(outputFile, outputData) fs.writeFileSync(outputFile, outputData)
console.log(`Exported to ${outputFile}`) console.log(`Exported to ${outputFile}`)
@ -88,22 +89,22 @@ function getSections(input: Asana, projectId: string): Workspace[] {
return [...sectionMap.values()] return [...sectionMap.values()]
} }
function convert(input: Asana): Block[] { function convert(input: Asana): [Board[], Block[]] {
const projects = getProjects(input) const projects = getProjects(input)
if (projects.length < 1) { if (projects.length < 1) {
console.error('No projects found') console.error('No projects found')
return [] return [[],[]]
} }
// TODO: Handle multiple projects // TODO: Handle multiple projects
const project = projects[0] const project = projects[0]
const boards: Board[] = []
const blocks: Block[] = [] const blocks: Block[] = []
// Board // Board
const board = createBoard() const board = createBoard()
console.log(`Board: ${project.name}`) console.log(`Board: ${project.name}`)
board.rootId = board.id
board.title = project.name board.title = project.name
// Convert sections (columns) to a Select property // Convert sections (columns) to a Select property
@ -130,14 +131,14 @@ function convert(input: Asana): Block[] {
options options
} }
board.cardProperties = [cardProperty] board.cardProperties = [cardProperty]
blocks.push(board) boards.push(board)
// Board view // Board view
const view = createBoardView() const view = createBoardView()
view.title = 'Board View' view.title = 'Board View'
view.fields.viewType = 'board' view.fields.viewType = 'board'
view.rootId = board.id
view.parentId = board.id view.parentId = board.id
view.boardId = board.id
blocks.push(view) blocks.push(view)
// Cards // Cards
@ -146,7 +147,7 @@ function convert(input: Asana): Block[] {
const outCard = createCard() const outCard = createCard()
outCard.title = card.name outCard.title = card.name
outCard.rootId = board.id outCard.boardId = board.id
outCard.parentId = board.id outCard.parentId = board.id
// Map lists to Select property options // Map lists to Select property options
@ -168,8 +169,8 @@ function convert(input: Asana): Block[] {
// console.log(`\t${card.notes}`) // console.log(`\t${card.notes}`)
const text = createTextBlock() const text = createTextBlock()
text.title = card.notes text.title = card.notes
text.rootId = board.id
text.parentId = outCard.id text.parentId = outCard.id
text.boardId = board.id
blocks.push(text) blocks.push(text)
outCard.fields.contentOrder = [text.id] outCard.fields.contentOrder = [text.id]
@ -179,7 +180,7 @@ function convert(input: Asana): Block[] {
console.log('') console.log('')
console.log(`Found ${input.data.length} card(s).`) console.log(`Found ${input.data.length} card(s).`)
return blocks return [boards, blocks]
} }
function showHelp() { function showHelp() {

View file

@ -3,7 +3,7 @@
import {run} from './jiraImporter' import {run} from './jiraImporter'
import * as fs from 'fs' import * as fs from 'fs'
import {ArchiveUtils} from '../../webapp/src/blocks/archive' import {ArchiveUtils} from '../util/archive'
const inputFile = './test/jira-export.xml' const inputFile = './test/jira-export.xml'
const outputFile = './test/jira.focalboard' const outputFile = './test/jira.focalboard'
@ -27,10 +27,6 @@ describe('import from Jira', () => {
expect(blocks).toEqual( expect(blocks).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({
title: 'Jira import',
type: 'board'
}),
expect.objectContaining({ expect.objectContaining({
title: 'Board View', title: 'Board View',
type: 'view' type: 'view'

View file

@ -4,6 +4,7 @@ import * as fs from 'fs'
import {exit} from 'process' import {exit} from 'process'
import {ArchiveUtils} from '../util/archive' import {ArchiveUtils} from '../util/archive'
import {Block} from '../../webapp/src/blocks/block' import {Block} from '../../webapp/src/blocks/block'
import {Board} from '../../webapp/src/blocks/board'
import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board'
import {createBoardView} from '../../webapp/src/blocks/boardView' import {createBoardView} from '../../webapp/src/blocks/boardView'
import {Card, createCard} from '../../webapp/src/blocks/card' import {Card, createCard} from '../../webapp/src/blocks/card'
@ -70,23 +71,23 @@ async function run(inputFile: string, outputFile: string): Promise<number> {
// console.dir(items); // console.dir(items);
// Convert // Convert
const blocks = convert(items) const [boards, blocks] = convert(items)
// Save output // Save output
// TODO: Stream output // TODO: Stream output
const outputData = ArchiveUtils.buildBlockArchive(blocks) const outputData = ArchiveUtils.buildBlockArchive(boards, blocks)
fs.writeFileSync(outputFile, outputData) fs.writeFileSync(outputFile, outputData)
console.log(`Exported ${blocks.length} block(s) to ${outputFile}`) console.log(`Exported ${blocks.length} block(s) to ${outputFile}`)
return blocks.length return blocks.length
} }
function convert(items: any[]) { function convert(items: any[]): [Board[], Block[]] {
const boards: Board[] = []
const blocks: Block[] = [] const blocks: Block[] = []
// Board // Board
const board = createBoard() const board = createBoard()
board.rootId = board.id
board.title = 'Jira import' board.title = 'Jira import'
// Compile standard properties // Compile standard properties
@ -126,13 +127,13 @@ function convert(items: any[]) {
} }
board.cardProperties.push(createdDateProperty) board.cardProperties.push(createdDateProperty)
blocks.push(board) boards.push(board)
// Board view // Board view
const view = createBoardView() const view = createBoardView()
view.title = 'Board View' view.title = 'Board View'
view.fields.viewType = 'board' view.fields.viewType = 'board'
view.rootId = board.id view.boardId = board.id
view.parentId = board.id view.parentId = board.id
blocks.push(view) blocks.push(view)
@ -145,7 +146,7 @@ function convert(items: any[]) {
const card = createCard() const card = createCard()
card.title = item.summary card.title = item.summary
card.rootId = board.id card.boardId = board.id
card.parentId = board.id card.parentId = board.id
// Map standard properties // Map standard properties
@ -169,7 +170,7 @@ function convert(items: any[]) {
console.log(`\t${description}`) console.log(`\t${description}`)
const text = createTextBlock() const text = createTextBlock()
text.title = description text.title = description
text.rootId = board.id text.boardId = board.id
text.parentId = card.id text.parentId = card.id
blocks.push(text) blocks.push(text)
@ -179,7 +180,7 @@ function convert(items: any[]) {
blocks.push(card) blocks.push(card)
} }
return blocks return [boards, blocks]
} }
function buildCardPropertyFromValues(propertyName: string, allValues: string[]) { function buildCardPropertyFromValues(propertyName: string, allValues: string[]) {

View file

@ -3,8 +3,9 @@
import * as fs from 'fs' import * as fs from 'fs'
import minimist from 'minimist' import minimist from 'minimist'
import {exit} from 'process' import {exit} from 'process'
import {ArchiveUtils} from '../../webapp/src/blocks/archive' import {ArchiveUtils} from '../util/archive'
import {Block} from '../../webapp/src/blocks/block' 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 {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board'
import {createBoardView} from '../../webapp/src/blocks/boardView' import {createBoardView} from '../../webapp/src/blocks/boardView'
import {createCard} from '../../webapp/src/blocks/card' import {createCard} from '../../webapp/src/blocks/card'
@ -69,10 +70,10 @@ async function main() {
})) }))
// Convert // Convert
const blocks = convert(board, stacks) const [boards, blocks] = convert(board, stacks)
// // Save output // // Save output
const outputData = ArchiveUtils.buildBlockArchive(blocks) const outputData = ArchiveUtils.buildBlockArchive(boards, blocks)
fs.writeFileSync(outputFile, outputData) fs.writeFileSync(outputFile, outputData)
console.log(`Exported to ${outputFile}`) console.log(`Exported to ${outputFile}`)
@ -85,13 +86,13 @@ async function selectBoard(deckClient: NextcloudDeckClient): Promise<number> {
return readline.questionInt("Enter Board ID: ") 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[] = [] const blocks: Block[] = []
// Board // Board
const board = createBoard() const board = createBoard()
console.log(`Board: ${deckBoard.title}`) console.log(`Board: ${deckBoard.title}`)
board.rootId = board.id
board.title = deckBoard.title board.title = deckBoard.title
let colorIndex = 0 let colorIndex = 0
@ -145,14 +146,14 @@ function convert(deckBoard: Board, stacks: Stack[]): Block[] {
options: [] options: []
} }
board.fields.cardProperties = [stackProperty, labelProperty, dueDateProperty] board.cardProperties = [stackProperty, labelProperty, dueDateProperty]
blocks.push(board) boards.push(board)
// Board view // Board view
const view = createBoardView() const view = createBoardView()
view.title = 'Board View' view.title = 'Board View'
view.fields.viewType = 'board' view.fields.viewType = 'board'
view.rootId = board.id view.boardId = board.id
view.parentId = board.id view.parentId = board.id
blocks.push(view) blocks.push(view)
@ -164,7 +165,7 @@ function convert(deckBoard: Board, stacks: Stack[]): Block[] {
const outCard = createCard() const outCard = createCard()
outCard.title = card.title outCard.title = card.title
outCard.rootId = board.id outCard.boardId = board.id
outCard.parentId = board.id outCard.parentId = board.id
// Map Stacks to Select property options // Map Stacks to Select property options
@ -189,7 +190,7 @@ function convert(deckBoard: Board, stacks: Stack[]): Block[] {
if (card.description) { if (card.description) {
const text = createTextBlock() const text = createTextBlock()
text.title = card.description text.title = card.description
text.rootId = board.id text.boardId = board.id
text.parentId = outCard.id text.parentId = outCard.id
blocks.push(text) blocks.push(text)
@ -200,7 +201,7 @@ function convert(deckBoard: Board, stacks: Stack[]): Block[] {
card.comments?.forEach(comment => { card.comments?.forEach(comment => {
const commentBlock = createCommentBlock() const commentBlock = createCommentBlock()
commentBlock.title = comment.message commentBlock.title = comment.message
commentBlock.rootId = board.id commentBlock.boardId = board.id
commentBlock.parentId = outCard.id commentBlock.parentId = outCard.id
blocks.push(commentBlock) blocks.push(commentBlock)
}) })
@ -210,7 +211,7 @@ function convert(deckBoard: Board, stacks: Stack[]): Block[] {
console.log('') console.log('')
console.log(`Transformed Board ${deckBoard.title} into ${blocks.length} blocks.`) console.log(`Transformed Board ${deckBoard.title} into ${blocks.length} blocks.`)
return blocks return [boards, blocks]
} }
function showHelp() { function showHelp() {

View file

@ -5,6 +5,7 @@ import path from 'path'
import {exit} from 'process' import {exit} from 'process'
import {ArchiveUtils} from '../util/archive' import {ArchiveUtils} from '../util/archive'
import {Block} from '../../webapp/src/blocks/block' import {Block} from '../../webapp/src/blocks/block'
import {Board} from '../../webapp/src/blocks/board'
import {IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board'
import {createBoardView} from '../../webapp/src/blocks/boardView' import {createBoardView} from '../../webapp/src/blocks/boardView'
import {createCard} from '../../webapp/src/blocks/card' import {createCard} from '../../webapp/src/blocks/card'
@ -70,11 +71,11 @@ async function main() {
markdownFolder = path.join(inputFolder, basename) markdownFolder = path.join(inputFolder, basename)
// Convert // Convert
const blocks = convert(input, title) const [boards, blocks] = convert(input, title)
// Save output // Save output
// TODO: Stream output // TODO: Stream output
const outputData = ArchiveUtils.buildBlockArchive(blocks) const outputData = ArchiveUtils.buildBlockArchive(boards, blocks)
fs.writeFileSync(outputFile, outputData) fs.writeFileSync(outputFile, outputData)
console.log(`Exported to ${outputFile}`) console.log(`Exported to ${outputFile}`)
@ -117,13 +118,13 @@ function getColumns(input: any[]) {
return keys.slice(1) 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[] = [] const blocks: Block[] = []
// Board // Board
const board = createBoard() const board = createBoard()
console.log(`Board: ${title}`) console.log(`Board: ${title}`)
board.rootId = board.id
board.title = title board.title = title
// Each column is a card property // Each column is a card property
@ -140,13 +141,13 @@ function convert(input: any[], title: string): Block[] {
// Set all column types to select // Set all column types to select
// TODO: Detect column type // TODO: Detect column type
blocks.push(board) boards.push(board)
// Board view // Board view
const view = createBoardView() const view = createBoardView()
view.title = 'Board View' view.title = 'Board View'
view.fields.viewType = 'board' view.fields.viewType = 'board'
view.rootId = board.id view.boardId = board.id
view.parentId = board.id view.parentId = board.id
blocks.push(view) blocks.push(view)
@ -166,7 +167,7 @@ function convert(input: any[], title: string): Block[] {
const outCard = createCard() const outCard = createCard()
outCard.title = title outCard.title = title
outCard.rootId = board.id outCard.boardId = board.id
outCard.parentId = board.id outCard.parentId = board.id
// Card properties, skip first key which is the title // 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`) console.log(`Markdown: ${markdown.length} bytes`)
const text = createTextBlock() const text = createTextBlock()
text.title = markdown text.title = markdown
text.rootId = board.id text.boardId = board.id
text.parentId = outCard.id text.parentId = outCard.id
blocks.push(text) blocks.push(text)
@ -212,7 +213,7 @@ function convert(input: any[], title: string): Block[] {
console.log('') console.log('')
console.log(`Found ${input.length} card(s).`) console.log(`Found ${input.length} card(s).`)
return blocks return [boards, blocks]
} }
function showHelp() { function showHelp() {

View file

@ -5,6 +5,7 @@ import minimist from 'minimist'
import {exit} from 'process' import {exit} from 'process'
import {ArchiveUtils} from '../util/archive' import {ArchiveUtils} from '../util/archive'
import {Block} from '../../webapp/src/blocks/block' import {Block} from '../../webapp/src/blocks/block'
import {Board} from '../../webapp/src/blocks/board'
import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board'
import {createBoardView} from '../../webapp/src/blocks/boardView' import {createBoardView} from '../../webapp/src/blocks/boardView'
import {createCard} from '../../webapp/src/blocks/card' import {createCard} from '../../webapp/src/blocks/card'
@ -56,31 +57,34 @@ function main() {
const inputData = fs.readFileSync(inputFile, 'utf-8') const inputData = fs.readFileSync(inputFile, 'utf-8')
const input = JSON.parse(inputData) as Todoist const input = JSON.parse(inputData) as Todoist
const boards = [] as Board[]
const blocks = [] as Block[] const blocks = [] as Block[]
input.projects.forEach(project => { input.projects.forEach(project => {
blocks.push(...convert(input, project)) const [brds, blks] = convert(input, project)
boards.push(...brds)
blocks.push(...blks)
}) })
// Save output // Save output
// TODO: Stream output // TODO: Stream output
const outputData = ArchiveUtils.buildBlockArchive(blocks) const outputData = ArchiveUtils.buildBlockArchive(boards, blocks)
fs.writeFileSync(outputFile, outputData) fs.writeFileSync(outputFile, outputData)
console.log(`Exported to ${outputFile}`) 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[] = [] const blocks: Block[] = []
if (project.name === 'Inbox') { if (project.name === 'Inbox') {
return blocks return [boards, blocks]
} }
// Board // Board
const board = createBoard() const board = createBoard()
console.log(`Board: ${project.name}`) console.log(`Board: ${project.name}`)
board.rootId = board.id
board.title = project.name board.title = project.name
board.description = project.name board.description = project.name
@ -115,13 +119,13 @@ function convert(input: Todoist, project: Project): Block[] {
options options
} }
board.cardProperties = [cardProperty] board.cardProperties = [cardProperty]
blocks.push(board) boards.push(board)
// Board view // Board view
const view = createBoardView() const view = createBoardView()
view.title = 'Board View' view.title = 'Board View'
view.fields.viewType = 'board' view.fields.viewType = 'board'
view.rootId = board.id view.boardId = board.id
view.parentId = board.id view.parentId = board.id
blocks.push(view) blocks.push(view)
@ -130,7 +134,7 @@ function convert(input: Todoist, project: Project): Block[] {
cards.forEach(card => { cards.forEach(card => {
const outCard = createCard() const outCard = createCard()
outCard.title = card.content outCard.title = card.content
outCard.rootId = board.id outCard.boardId = board.id
outCard.parentId = board.id outCard.parentId = board.id
// Map lists to Select property options // Map lists to Select property options
@ -148,14 +152,14 @@ function convert(input: Todoist, project: Project): Block[] {
// console.log(`\t${card.desc}`) // console.log(`\t${card.desc}`)
const text = createTextBlock() const text = createTextBlock()
text.title = getCardDescription(input, card).join('\n\n') text.title = getCardDescription(input, card).join('\n\n')
text.rootId = board.id text.boardId = board.id
text.parentId = outCard.id text.parentId = outCard.id
blocks.push(text) blocks.push(text)
outCard.fields.contentOrder = [text.id] outCard.fields.contentOrder = [text.id]
}) })
return blocks return [boards, blocks]
} }
function getProjectColumns(input: Todoist, project: Project): Array<Section> { function getProjectColumns(input: Todoist, project: Project): Array<Section> {

View file

@ -5,6 +5,7 @@ import minimist from 'minimist'
import {exit} from 'process' import {exit} from 'process'
import {ArchiveUtils} from '../util/archive' import {ArchiveUtils} from '../util/archive'
import {Block} from '../../webapp/src/blocks/block' import {Block} from '../../webapp/src/blocks/block'
import {Board} from '../../webapp/src/blocks/board'
import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board'
import {createBoardView} from '../../webapp/src/blocks/boardView' import {createBoardView} from '../../webapp/src/blocks/boardView'
import {createCard} from '../../webapp/src/blocks/card' import {createCard} from '../../webapp/src/blocks/card'
@ -50,23 +51,23 @@ function main() {
const input = JSON.parse(inputData) as Trello const input = JSON.parse(inputData) as Trello
// Convert // Convert
const blocks = convert(input) const [boards, blocks] = convert(input)
// Save output // Save output
// TODO: Stream output // TODO: Stream output
const outputData = ArchiveUtils.buildBlockArchive(blocks) const outputData = ArchiveUtils.buildBlockArchive(boards, blocks)
fs.writeFileSync(outputFile, outputData) fs.writeFileSync(outputFile, outputData)
console.log(`Exported to ${outputFile}`) console.log(`Exported to ${outputFile}`)
} }
function convert(input: Trello): Block[] { function convert(input: Trello): [Board[], Block[]] {
const boards: Board[] = []
const blocks: Block[] = [] const blocks: Block[] = []
// Board // Board
const board = createBoard() const board = createBoard()
console.log(`Board: ${input.name}`) console.log(`Board: ${input.name}`)
board.rootId = board.id
board.title = input.name board.title = input.name
board.description = input.desc board.description = input.desc
@ -93,13 +94,13 @@ function convert(input: Trello): Block[] {
options options
} }
board.cardProperties = [cardProperty] board.cardProperties = [cardProperty]
blocks.push(board) boards.push(board)
// Board view // Board view
const view = createBoardView() const view = createBoardView()
view.title = 'Board View' view.title = 'Board View'
view.fields.viewType = 'board' view.fields.viewType = 'board'
view.rootId = board.id view.boardId = board.id
view.parentId = board.id view.parentId = board.id
blocks.push(view) blocks.push(view)
@ -109,7 +110,7 @@ function convert(input: Trello): Block[] {
const outCard = createCard() const outCard = createCard()
outCard.title = card.name outCard.title = card.name
outCard.rootId = board.id outCard.boardId = board.id
outCard.parentId = board.id outCard.parentId = board.id
// Map lists to Select property options // Map lists to Select property options
@ -130,7 +131,7 @@ function convert(input: Trello): Block[] {
// console.log(`\t${card.desc}`) // console.log(`\t${card.desc}`)
const text = createTextBlock() const text = createTextBlock()
text.title = card.desc text.title = card.desc
text.rootId = board.id text.boardId = board.id
text.parentId = outCard.id text.parentId = outCard.id
blocks.push(text) blocks.push(text)
@ -150,7 +151,7 @@ function convert(input: Trello): Block[] {
} else { } else {
checkBlock.fields.value = false checkBlock.fields.value = false
} }
checkBlock.rootId = outCard.rootId checkBlock.boardId = board.id
checkBlock.parentId = outCard.id checkBlock.parentId = outCard.id
blocks.push(checkBlock) blocks.push(checkBlock)
@ -164,7 +165,7 @@ function convert(input: Trello): Block[] {
console.log('') console.log('')
console.log(`Found ${input.cards.length} card(s).`) console.log(`Found ${input.cards.length} card(s).`)
return blocks return [boards, blocks]
} }
function showHelp() { function showHelp() {

View file

@ -1,25 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {Block} from '../../webapp/src/blocks/block' import {Block} from '../../webapp/src/blocks/block'
import {Board} from '../../webapp/src/blocks/board'
interface ArchiveHeader { interface ArchiveHeader {
version: number version: number
date: number date: number
} }
// This schema allows the expansion of additional line types in the future
interface ArchiveLine { interface ArchiveLine {
type: string, type: string,
data: unknown, data: unknown,
} }
// This schema allows the expansion of additional line types in the future
interface BlockArchiveLine extends ArchiveLine { interface BlockArchiveLine extends ArchiveLine {
type: 'block', type: 'block',
data: Block data: Block
} }
interface BoardArchiveLine extends ArchiveLine {
type: 'board',
data: Board
}
class ArchiveUtils { class ArchiveUtils {
static buildBlockArchive(blocks: readonly Block[]): string { static buildBlockArchive(boards: readonly Board[], blocks: readonly Block[]): string {
const header: ArchiveHeader = { const header: ArchiveHeader = {
version: 1, version: 1,
date: Date.now(), date: Date.now(),
@ -27,6 +33,17 @@ class ArchiveUtils {
const headerString = JSON.stringify(header) const headerString = JSON.stringify(header)
let content = headerString + '\n' 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) { for (const block of blocks) {
const line: BlockArchiveLine = { const line: BlockArchiveLine = {
type: 'block', type: 'block',

View file

@ -87,14 +87,9 @@ func (a *API) RegisterRoutes(r *mux.Router) {
apiv1.HandleFunc("/boards/{boardID}/blocks/{blockID}", a.sessionRequired(a.handleDeleteBlock)).Methods("DELETE") apiv1.HandleFunc("/boards/{boardID}/blocks/{blockID}", a.sessionRequired(a.handleDeleteBlock)).Methods("DELETE")
apiv1.HandleFunc("/boards/{boardID}/blocks/{blockID}", a.sessionRequired(a.handlePatchBlock)).Methods("PATCH") 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}/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}/blocks/{blockID}/duplicate", a.attachSession(a.handleDuplicateBlock, false)).Methods("POST")
apiv1.HandleFunc("/boards/{boardID}/metadata", a.sessionRequired(a.handleGetBoardMetadata)).Methods("GET") 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 // Member APIs
apiv1.HandleFunc("/boards/{boardID}/members", a.sessionRequired(a.handleGetMembersForBoard)).Methods("GET") apiv1.HandleFunc("/boards/{boardID}/members", a.sessionRequired(a.handleGetMembersForBoard)).Methods("GET")
apiv1.HandleFunc("/boards/{boardID}/members", a.sessionRequired(a.handleAddMember)).Methods("POST") apiv1.HandleFunc("/boards/{boardID}/members", a.sessionRequired(a.handleAddMember)).Methods("POST")
@ -155,7 +150,7 @@ func (a *API) RegisterRoutes(r *mux.Router) {
// archives // archives
apiv1.HandleFunc("/boards/{boardID}/archive/export", a.sessionRequired(a.handleArchiveExportBoard)).Methods("GET") 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) { func (a *API) RegisterAdminRoutes(r *mux.Router) {
@ -363,23 +358,6 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
auditRec.Success() 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) { func (a *API) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
requestBody, err := ioutil.ReadAll(r.Body) requestBody, err := ioutil.ReadAll(r.Body)
if err != nil { if err != nil {
@ -1219,287 +1197,6 @@ func (a *API) handlePatchBlocks(w http.ResponseWriter, r *http.Request) {
auditRec.Success() 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 // Sharing
func (a *API) handleGetSharing(w http.ResponseWriter, r *http.Request) { 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 // description: internal error
// schema: // schema:
// "$ref": "#/definitions/ErrorResponse" // "$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() team, err := a.app.GetRootTeam()
if err != nil { if err != nil {
@ -1824,7 +1524,7 @@ func (a *API) handlePostTeamRegenerateSignupToken(w http.ResponseWriter, r *http
// File upload // File upload
func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) { 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 // Returns the contents of an uploaded file
// //
@ -1835,19 +1535,19 @@ func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) {
// - image/png // - image/png
// - image/gif // - image/gif
// parameters: // parameters:
// - name: teamID
// in: path
// description: Team ID
// required: true
// type: string
// - name: boardID // - name: boardID
// in: path // in: path
// description: Board ID // description: Board ID
// required: true // required: true
// type: string // type: string
// - name: rootID // - name: filename
// in: path // in: path
// description: ID of the root block // description: name of the file
// required: true
// type: string
// - name: fileID
// in: path
// description: ID of the file
// required: true // required: true
// type: string // type: string
// security: // security:
@ -1865,7 +1565,8 @@ func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) {
filename := vars["filename"] filename := vars["filename"]
userID := getUserID(r) 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"}) a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"})
return return
} }
@ -2188,7 +1889,7 @@ func (a *API) handleGetTemplates(w http.ResponseWriter, r *http.Request) {
auditRec.AddMeta("teamID", teamID) auditRec.AddMeta("teamID", teamID)
// retrieve boards list // retrieve boards list
boards, err := a.app.GetTemplateBoards(teamID) boards, err := a.app.GetTemplateBoards(teamID, userID)
if err != nil { if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return return
@ -2831,8 +2532,7 @@ func (a *API) handleDuplicateBoard(w http.ResponseWriter, r *http.Request) {
return return
} }
hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID) if userID == "" {
if userID == "" && !hasValidReadToken {
a.errorResponse(w, r.URL.Path, http.StatusUnauthorized, "", PermissionError{"access denied to board"}) a.errorResponse(w, r.URL.Path, http.StatusUnauthorized, "", PermissionError{"access denied to board"})
return return
} }
@ -2847,17 +2547,15 @@ func (a *API) handleDuplicateBoard(w http.ResponseWriter, r *http.Request) {
return return
} }
if !hasValidReadToken { if board.Type == model.BoardTypePrivate {
if board.Type == model.BoardTypePrivate { if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"})
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"}) return
return }
} } else {
} else { if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) { a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"})
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"}) return
return
}
} }
} }
@ -2926,8 +2624,7 @@ func (a *API) handleDuplicateBlock(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query() query := r.URL.Query()
asTemplate := query.Get("asTemplate") asTemplate := query.Get("asTemplate")
hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID) if userID == "" {
if userID == "" && !hasValidReadToken {
a.errorResponse(w, r.URL.Path, http.StatusUnauthorized, "", PermissionError{"access denied to board"}) a.errorResponse(w, r.URL.Path, http.StatusUnauthorized, "", PermissionError{"access denied to board"})
return return
} }

View file

@ -8,6 +8,8 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/services/audit" "github.com/mattermost/focalboard/server/services/audit"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
) )
const ( const (
@ -103,6 +105,9 @@ func (a *API) handleArchiveExportTeam(w http.ResponseWriter, r *http.Request) {
// description: internal error // description: internal error
// schema: // schema:
// "$ref": "#/definitions/ErrorResponse" // "$ref": "#/definitions/ErrorResponse"
if a.MattermostAuth {
a.errorResponse(w, r.URL.Path, http.StatusNotImplemented, "not permitted in plugin mode", nil)
}
vars := mux.Vars(r) vars := mux.Vars(r)
teamID := vars["teamID"] 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) { 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. // Import an archive of boards.
// //
@ -153,9 +158,9 @@ func (a *API) handleArchiveImport(w http.ResponseWriter, r *http.Request) {
// consumes: // consumes:
// - multipart/form-data // - multipart/form-data
// parameters: // parameters:
// - name: boardID // - name: teamID
// in: path // in: path
// description: Workspace ID // description: Team ID
// required: true // required: true
// type: string // type: string
// - name: file // - 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 { 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) a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return return
} }

View file

@ -166,6 +166,9 @@ func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
// description: internal error // description: internal error
// schema: // schema:
// "$ref": "#/definitions/ErrorResponse" // "$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 { if len(a.singleUserToken) > 0 {
// Not permitted in single-user mode // Not permitted in single-user mode
@ -228,6 +231,9 @@ func (a *API) handleLogout(w http.ResponseWriter, r *http.Request) {
// description: internal error // description: internal error
// schema: // schema:
// "$ref": "#/definitions/ErrorResponse" // "$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 { if len(a.singleUserToken) > 0 {
// Not permitted in single-user mode // Not permitted in single-user mode
@ -278,6 +284,9 @@ func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) {
// description: internal error // description: internal error
// schema: // schema:
// "$ref": "#/definitions/ErrorResponse" // "$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 { if len(a.singleUserToken) > 0 {
// Not permitted in single-user mode // Not permitted in single-user mode
@ -377,6 +386,9 @@ func (a *API) handleChangePassword(w http.ResponseWriter, r *http.Request) {
// description: internal error // description: internal error
// schema: // schema:
// "$ref": "#/definitions/ErrorResponse" // "$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 { if len(a.singleUserToken) > 0 {
// Not permitted in single-user mode // Not permitted in single-user mode
@ -458,6 +470,18 @@ func (a *API) attachSession(handler func(w http.ResponseWriter, r *http.Request)
CreateAt: now, CreateAt: now,
UpdateAt: 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) ctx := context.WithValue(r.Context(), sessionContextKey, session)
handler(w, r.WithContext(ctx)) handler(w, r.WithContext(ctx))
return return

View file

@ -234,15 +234,6 @@ func (a *App) CopyCardFiles(sourceBoardID string, blocks []model.Block) error {
return nil 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) { func (a *App) GetBlockByID(blockID string) (*model.Block, error) {
return a.store.GetBlock(blockID) return a.store.GetBlock(blockID)
} }

View file

@ -69,6 +69,21 @@ func (a *App) GetBoardMetadata(boardID string) (*model.Board, *model.BoardMetada
return board, &boardMetadata, nil 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) { func (a *App) getBoardHistory(boardID string, latest bool) (*model.Board, error) {
opts := model.QueryBlockHistoryOptions{ opts := model.QueryBlockHistoryOptions{
Limit: 1, Limit: 1,
@ -150,8 +165,8 @@ func (a *App) GetBoardsForUserAndTeam(userID, teamID string) ([]*model.Board, er
return a.store.GetBoardsForUserAndTeam(userID, teamID) return a.store.GetBoardsForUserAndTeam(userID, teamID)
} }
func (a *App) GetTemplateBoards(teamID string) ([]*model.Board, error) { func (a *App) GetTemplateBoards(teamID, userID string) ([]*model.Board, error) {
return a.store.GetTemplateBoards(teamID) return a.store.GetTemplateBoards(teamID, userID)
} }
func (a *App) CreateBoard(board *model.Board, userID string, addMember bool) (*model.Board, error) { func (a *App) CreateBoard(board *model.Board, userID string, addMember bool) (*model.Board, error) {

View file

@ -120,11 +120,12 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
var boardID string var boardID string
lineNum := 1 lineNum := 1
firstLine := true
for { for {
line, errRead := readLine(lineReader) line, errRead := readLine(lineReader)
if len(line) != 0 { if len(line) != 0 {
var skip bool var skip bool
if lineNum == 1 { if firstLine {
// first line might be a header tag (old archive format) // first line might be a header tag (old archive format)
if strings.HasPrefix(string(line), legacyFileBegin) { if strings.HasPrefix(string(line), legacyFileBegin) {
skip = true skip = true
@ -138,7 +139,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
} }
// first line must be a board // first line must be a board
if lineNum == 1 && archiveLine.Type == "block" { if firstLine && archiveLine.Type == "block" {
archiveLine.Type = "board_block" archiveLine.Type = "board_block"
} }
@ -179,6 +180,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
default: default:
return "", model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type) 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) 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 // find new board id
for _, board := range boardsAndBlocks.Boards { for _, board := range boardsAndBlocks.Boards {
return board.ID, nil return board.ID, nil

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

@ -0,0 +1,69 @@
package app
import (
"bytes"
"testing"
"github.com/golang/mock/gomock"
"github.com/mattermost/focalboard/server/model"
"github.com/stretchr/testify/require"
)
func TestApp_ImportArchive(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
board := &model.Board{
ID: "d14b9df9-1f31-4732-8a64-92bc7162cd28",
TeamID: "test-team",
Title: "Cross-Functional Project Plan",
}
block := model.Block{
ID: "2c1873e0-1484-407d-8b2c-3c3b5a2a9f9e",
ParentID: board.ID,
Type: model.TypeView,
BoardID: board.ID,
}
babs := &model.BoardsAndBlocks{
Boards: []*model.Board{board},
Blocks: []model.Block{block},
}
boardMember := &model.BoardMember{
BoardID: board.ID,
UserID: "user",
}
t.Run("import asana archive", func(t *testing.T) {
r := bytes.NewReader([]byte(asana))
opts := model.ImportArchiveOptions{
TeamID: "test-team",
ModifiedBy: "user",
}
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.AssignableToTypeOf(&model.BoardsAndBlocks{}), "user").Return(babs, nil)
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{boardMember}, nil)
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
th.Store.EXPECT().GetMemberForBoard(board.ID, "user").Return(boardMember, nil)
err := th.App.ImportArchive(r, opts)
require.NoError(t, err, "import archive should not fail")
})
}
//nolint:lll
const asana = `{"version":1,"date":1614714686842}
{"type":"block","data":{"id":"d14b9df9-1f31-4732-8a64-92bc7162cd28","fields":{"icon":"","description":"","cardProperties":[{"id":"3bdcbaeb-bc78-4884-8531-a0323b74676a","name":"Section","type":"select","options":[{"id":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732","value":"Planning","color":"propColorGray"},{"id":"454559bb-b788-4ff6-873e-04def8491d2c","value":"Milestones","color":"propColorBrown"},{"id":"deaab476-c690-48df-828f-725b064dc476","value":"Next steps","color":"propColorOrange"},{"id":"2138305a-3157-461c-8bbe-f19ebb55846d","value":"Comms Plan","color":"propColorYellow"}]}]},"createAt":1614714686836,"updateAt":1614714686836,"deleteAt":0,"schema":1,"parentId":"","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"board","title":"Cross-Functional Project Plan"}}
{"type":"block","data":{"id":"2c1873e0-1484-407d-8b2c-3c3b5a2a9f9e","fields":{"sortOptions":[],"visiblePropertyIds":[],"visibleOptionIds":[],"hiddenOptionIds":[],"filter":{"operation":"and","filters":[]},"cardOrder":[],"columnWidths":{},"viewType":"board"},"createAt":1614714686840,"updateAt":1614714686840,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"view","title":"Board View"}}
{"type":"block","data":{"id":"520c332b-adf5-4a32-88ab-43655c8b6aa2","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732"},"contentOrder":["deb3966c-6d56-43b1-8e95-36806877ce81"]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[READ ME] - Instructions for using this template"}}
{"type":"block","data":{"id":"deb3966c-6d56-43b1-8e95-36806877ce81","fields":{},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"520c332b-adf5-4a32-88ab-43655c8b6aa2","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"text","title":"This project template is set up in List View with sections and Asana-created Custom Fields to help you track your team's work. We've provided some example content in this template to get you started, but you should add tasks, change task names, add more Custom Fields, and change any other info to make this project your own.\n\nSend feedback about this template: https://asa.na/templatesfeedback"}}
{"type":"block","data":{"id":"be791f66-a5e5-4408-82f6-cb1280f5bc45","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732"},"contentOrder":["2688b31f-e7ff-4de1-87ae-d4b5570f8712"]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"Redesign the landing page of our website"}}
{"type":"block","data":{"id":"2688b31f-e7ff-4de1-87ae-d4b5570f8712","fields":{},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"be791f66-a5e5-4408-82f6-cb1280f5bc45","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"text","title":"Redesign the landing page to focus on the main persona."}}
{"type":"block","data":{"id":"98f74948-1700-4a3c-8cc2-8bb632499def","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"454559bb-b788-4ff6-873e-04def8491d2c"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Consider trying a new email marketing service"}}
{"type":"block","data":{"id":"142fba5d-05e6-4865-83d9-b3f54d9de96e","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"454559bb-b788-4ff6-873e-04def8491d2c"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Budget finalization"}}
{"type":"block","data":{"id":"ca6670b1-b034-4e42-8971-c659b478b9e0","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"deaab476-c690-48df-828f-725b064dc476"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Find a venue for the holiday party"}}
{"type":"block","data":{"id":"db1dd596-0999-4741-8b05-72ca8e438e31","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"deaab476-c690-48df-828f-725b064dc476"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Approve campaign copy"}}
{"type":"block","data":{"id":"16861c05-f31f-46af-8429-80a87b5aa93a","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"2138305a-3157-461c-8bbe-f19ebb55846d"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Send out updated attendee list"}}
`

View file

@ -46,14 +46,14 @@ func (a *App) PrepareOnboardingTour(userID string, teamID string) (string, strin
} }
func (a *App) getOnboardingBoardID() (string, error) { func (a *App) getOnboardingBoardID() (string, error) {
boards, err := a.store.GetTemplateBoards(globalTeamID) boards, err := a.store.GetTemplateBoards(globalTeamID, "")
if err != nil { if err != nil {
return "", err return "", err
} }
var onboardingBoardID string var onboardingBoardID string
for _, block := range boards { for _, block := range boards {
if block.Title == WelcomeBoardTitle { if block.Title == WelcomeBoardTitle && block.TeamID == globalTeamID {
onboardingBoardID = block.ID onboardingBoardID = block.ID
break break
} }

View file

@ -25,7 +25,7 @@ func TestPrepareOnboardingTour(t *testing.T) {
IsTemplate: true, 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}}, th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}},
nil, nil) nil, nil)
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(2) th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(2)
@ -70,7 +70,7 @@ func TestCreateWelcomeBoard(t *testing.T) {
TeamID: "0", TeamID: "0",
IsTemplate: true, 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). th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).
Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}}, nil, nil) Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}}, nil, nil)
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(2) 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) { t.Run("template doesn't contain a board", func(t *testing.T) {
teamID := testTeamID 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) boardID, err := th.App.createWelcomeBoard("user_id_1", teamID)
assert.Error(t, err) assert.Error(t, err)
assert.Empty(t, boardID) assert.Empty(t, boardID)
@ -105,7 +105,7 @@ func TestCreateWelcomeBoard(t *testing.T) {
TeamID: teamID, TeamID: teamID,
IsTemplate: true, 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") boardID, err := th.App.createWelcomeBoard("user_id_1", "workspace_id_1")
assert.Error(t, err) assert.Error(t, err)
assert.Empty(t, boardID) assert.Empty(t, boardID)
@ -123,7 +123,7 @@ func TestGetOnboardingBoardID(t *testing.T) {
TeamID: "0", TeamID: "0",
IsTemplate: true, 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() onboardingBoardID, err := th.App.getOnboardingBoardID()
assert.NoError(t, err) assert.NoError(t, err)
@ -131,7 +131,7 @@ func TestGetOnboardingBoardID(t *testing.T) {
}) })
t.Run("no blocks found", func(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() onboardingBoardID, err := th.App.getOnboardingBoardID()
assert.Error(t, err) assert.Error(t, err)
@ -145,7 +145,7 @@ func TestGetOnboardingBoardID(t *testing.T) {
TeamID: "0", TeamID: "0",
IsTemplate: true, 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() onboardingBoardID, err := th.App.getOnboardingBoardID()
assert.Error(t, err) assert.Error(t, err)

View file

@ -3,6 +3,8 @@ package app
import ( import (
"github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/utils" "github.com/mattermost/focalboard/server/utils"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
) )
func (a *App) CreateSubscription(sub *model.Subscription) (*model.Subscription, error) { 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 { if a.notifications == nil {
return return
} }
a.notifications.BroadcastSubscriptionChange(subscription)
board, err := a.getBoardForBlock(subscription.BlockID)
if err != nil {
a.logger.Error("Error notifying subscription change",
mlog.String("subscriber_id", subscription.SubscriberID),
mlog.String("block_id", subscription.BlockID),
mlog.Err(err),
)
}
a.notifications.BroadcastSubscriptionChange(board.TeamID, subscription)
} }

View file

@ -23,7 +23,7 @@ func (a *App) InitTemplates() error {
// initializeTemplates imports default templates if the boards table is empty. // initializeTemplates imports default templates if the boards table is empty.
func (a *App) initializeTemplates() (bool, error) { func (a *App) initializeTemplates() (bool, error) {
boards, err := a.store.GetTemplateBoards(globalTeamID) boards, err := a.store.GetTemplateBoards(globalTeamID, "")
if err != nil { if err != nil {
return false, fmt.Errorf("cannot initialize templates: %w", err) return false, fmt.Errorf("cannot initialize templates: %w", err)
} }

View file

@ -34,14 +34,21 @@ func TestApp_initializeTemplates(t *testing.T) {
Blocks: []model.Block{block}, Blocks: []model.Block{block},
} }
boardMember := &model.BoardMember{
BoardID: board.ID,
UserID: "test-user",
}
t.Run("Needs template init", func(t *testing.T) { t.Run("Needs template init", func(t *testing.T) {
th, tearDown := SetupTestHelper(t) th, tearDown := SetupTestHelper(t)
defer tearDown() 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().RemoveDefaultTemplates([]*model.Board{}).Return(nil)
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.Any(), gomock.Any()).AnyTimes().Return(boardsAndBlocks, 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().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) 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) th, tearDown := SetupTestHelper(t)
defer tearDown() 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() done, err := th.App.initializeTemplates()
require.NoError(t, err, "initializeTemplates should not error") require.NoError(t, err, "initializeTemplates should not error")

View file

@ -42,7 +42,7 @@ func setupTestHelper(t *testing.T) *TestHelper {
newAuth := New(&cfg, mockStore, localpermissions.New(mockPermissions, logger)) newAuth := New(&cfg, mockStore, localpermissions.New(mockPermissions, logger))
// called during default template setup for every test // 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().RemoveDefaultTemplates(gomock.Any()).AnyTimes()
mockStore.EXPECT().InsertBlock(gomock.Any(), gomock.Any()).AnyTimes() mockStore.EXPECT().InsertBlock(gomock.Any(), gomock.Any()).AnyTimes()

View file

@ -164,10 +164,6 @@ func (c *Client) GetBlockRoute(boardID, blockID string) string {
return fmt.Sprintf("%s/%s", c.GetBlocksRoute(boardID), blockID) 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 { func (c *Client) GetBoardsRoute() string {
return "/boards" return "/boards"
} }
@ -297,16 +293,6 @@ func (c *Client) DeleteBlock(boardID, blockID string) (bool, *Response) {
return true, BuildResponse(r) 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. // Boards and blocks.
func (c *Client) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks) (*model.BoardsAndBlocks, *Response) { func (c *Client) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks) (*model.BoardsAndBlocks, *Response) {
r, err := c.DoAPIPost(c.GetBoardsAndBlocksRoute(), toJSON(bab)) r, err := c.DoAPIPost(c.GetBoardsAndBlocksRoute(), toJSON(bab))

View file

@ -429,18 +429,4 @@ func TestGetSubtree(t *testing.T) {
} }
require.Contains(t, blockIDs, parentBlockID) require.Contains(t, blockIDs, parentBlockID)
}) })
t.Run("Get subtree for parent ID", func(t *testing.T) {
blocks, resp := th.Client.GetSubtree(board.ID, parentBlockID)
require.NoError(t, resp.Error)
require.Len(t, blocks, 3)
blockIDs := make([]string, len(blocks))
for i, b := range blocks {
blockIDs[i] = b.ID
}
require.Contains(t, blockIDs, parentBlockID)
require.Contains(t, blockIDs, childBlockID1)
require.Contains(t, blockIDs, childBlockID2)
})
} }

View file

@ -55,6 +55,10 @@ type User struct {
// If the user is a bot or not // If the user is a bot or not
// required: true // required: true
IsBot bool `json:"is_bot"` 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 // UserPropPatch is a user property patch

View file

@ -205,7 +205,7 @@ func (b *Backend) OnMention(userID string, evt notify.BlockChangeEvent) {
} }
// BroadcastSubscriptionChange sends a websocket message with details of the changed subscription to all // BroadcastSubscriptionChange sends a websocket message with details of the changed subscription to all
// connected users in the workspace. // connected users in the team.
func (b *Backend) BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) { func (b *Backend) BroadcastSubscriptionChange(teamID string, subscription *model.Subscription) {
b.wsAdapter.BroadcastSubscriptionChange(workspaceID, subscription) b.wsAdapter.BroadcastSubscriptionChange(teamID, subscription)
} }

View file

@ -31,7 +31,7 @@ type BlockChangeEvent struct {
} }
type SubscriptionChangeNotifier interface { type SubscriptionChangeNotifier interface {
BroadcastSubscriptionChange(subscription *model.Subscription) BroadcastSubscriptionChange(teamID string, subscription *model.Subscription)
} }
// Backend provides an interface for sending notifications. // 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 // BroadcastSubscriptionChange sends a websocket message with details of the changed subscription to all
// connected users in the workspace. // connected users in the workspace.
func (s *Service) BroadcastSubscriptionChange(subscription *model.Subscription) { func (s *Service) BroadcastSubscriptionChange(teamID string, subscription *model.Subscription) {
s.mux.RLock() s.mux.RLock()
backends := make([]Backend, len(s.backends)) backends := make([]Backend, len(s.backends))
copy(backends, 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("block_id", subscription.BlockID),
mlog.String("subscriber_id", subscription.SubscriberID), mlog.String("subscriber_id", subscription.SubscriberID),
) )
scn.BroadcastSubscriptionChange(subscription) scn.BroadcastSubscriptionChange(teamID, subscription)
} }
} }
} }

View file

@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin" "github.com/mattermost/mattermost-server/v6/plugin"
sq "github.com/Masterminds/squirrel" sq "github.com/Masterminds/squirrel"
@ -12,7 +13,6 @@ import (
"github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/services/store"
"github.com/mattermost/focalboard/server/utils" "github.com/mattermost/focalboard/server/utils"
mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/mlog" "github.com/mattermost/mattermost-server/v6/shared/mlog"
) )
@ -55,7 +55,8 @@ func (s *MattermostAuthLayer) GetRegisteredUserCount() (int, error) {
query := s.getQueryBuilder(). query := s.getQueryBuilder().
Select("count(*)"). Select("count(*)").
From("Users"). From("Users").
Where(sq.Eq{"deleteAt": 0}) Where(sq.Eq{"deleteAt": 0}).
Where(sq.NotEq{"roles": "system_guest"})
row := query.QueryRow() row := query.QueryRow()
var count int var count int
@ -67,67 +68,31 @@ func (s *MattermostAuthLayer) GetRegisteredUserCount() (int, error) {
return count, nil 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) { 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) { 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) { 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 { 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"). Join("TeamMembers as tm ON tm.UserID = u.ID").
LeftJoin("Bots b ON ( b.UserId = Users.ID )"). LeftJoin("Bots b ON ( b.UserId = Users.ID )").
Where(sq.Eq{"u.deleteAt": 0}). Where(sq.Eq{"u.deleteAt": 0}).
Where(sq.NotEq{"u.roles": "system_guest"}).
Where(sq.Eq{"tm.TeamId": teamID}) Where(sq.Eq{"tm.TeamId": teamID})
rows, err := query.Query() rows, err := query.Query()
@ -324,6 +290,7 @@ func (s *MattermostAuthLayer) SearchUsersByTeam(teamID string, searchQuery strin
sq.Like{"u.lastname": "%" + searchQuery + "%"}, sq.Like{"u.lastname": "%" + searchQuery + "%"},
}). }).
Where(sq.Eq{"tm.TeamId": teamID}). Where(sq.Eq{"tm.TeamId": teamID}).
Where(sq.NotEq{"u.roles": "system_guest"}).
OrderBy("u.username"). OrderBy("u.username").
Limit(10) Limit(10)
@ -390,6 +357,32 @@ func (s *MattermostAuthLayer) CreatePrivateWorkspace(userID string) (string, err
return channel.Id, nil 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 { func (s *MattermostAuthLayer) GetLicense() *mmModel.License {
return s.pluginAPI.GetLicense() return s.pluginAPI.GetLicense()
} }

View file

@ -881,18 +881,18 @@ func (mr *MockStoreMockRecorder) GetTeamsForUser(arg0 interface{}) *gomock.Call
} }
// GetTemplateBoards mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTemplateBoards", arg0) ret := m.ctrl.Call(m, "GetTemplateBoards", arg0, arg1)
ret0, _ := ret[0].([]*model.Board) ret0, _ := ret[0].([]*model.Board)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
// GetTemplateBoards indicates an expected call of GetTemplateBoards. // 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() 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. // GetUserByEmail mocks base method.

View file

@ -435,8 +435,8 @@ func (s *SQLStore) GetTeamsForUser(userID string) ([]*model.Team, error) {
} }
func (s *SQLStore) GetTemplateBoards(teamID string) ([]*model.Board, error) { func (s *SQLStore) GetTemplateBoards(teamID string, userID string) ([]*model.Board, error) {
return s.getTemplateBoards(s.db, teamID) return s.getTemplateBoards(s.db, teamID, userID)
} }

View file

@ -54,13 +54,24 @@ func (s *SQLStore) removeDefaultTemplates(db sq.BaseRunner, boards []*model.Boar
return nil return nil
} }
// getDefaultTemplateBoards fetches all template blocks . // getTemplateBoards fetches all template boards .
func (s *SQLStore) getTemplateBoards(db sq.BaseRunner, teamID string) ([]*model.Board, error) { func (s *SQLStore) getTemplateBoards(db sq.BaseRunner, teamID, userID string) ([]*model.Board, error) {
query := s.getQueryBuilder(db). query := s.getQueryBuilder(db).
Select(boardFields("")...). Select(boardFields("")...).
From(s.tablePrefix + "boards"). From(s.tablePrefix+"boards as b").
Where(sq.Eq{"coalesce(team_id, '0')": teamID}). 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{"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() rows, err := query.Query()
if err != nil { if err != nil {
@ -69,5 +80,10 @@ func (s *SQLStore) getTemplateBoards(db sq.BaseRunner, teamID string) ([]*model.
} }
defer s.CloseRows(rows) defer s.CloseRows(rows)
return s.boardsFromRows(rows) userTemplates, err := s.boardsFromRows(rows)
if err != nil {
return nil, err
}
return userTemplates, nil
} }

View file

@ -129,7 +129,7 @@ type Store interface {
GetNextNotificationHint(remove bool) (*model.NotificationHint, error) GetNextNotificationHint(remove bool) (*model.NotificationHint, error)
RemoveDefaultTemplates(boards []*model.Board) error RemoveDefaultTemplates(boards []*model.Board) error
GetTemplateBoards(teamID string) ([]*model.Board, error) GetTemplateBoards(teamID, userID string) ([]*model.Board, error)
DBType() string DBType() string

View file

@ -8,10 +8,8 @@ describe('Login actions', () => {
it('Can perform login/register actions', () => { it('Can perform login/register actions', () => {
// Redirects to login page // Redirects to login page
cy.log('**Redirects to error then login page**') cy.log('**Redirects to login page (except plugin mode) **')
cy.visit('/') cy.visit('/')
cy.location('pathname').should('eq', '/error')
cy.get('button').contains('Log in').click()
cy.location('pathname').should('eq', '/login') cy.location('pathname').should('eq', '/login')
cy.get('.LoginPage').contains('Log in') cy.get('.LoginPage').contains('Log in')
cy.get('#login-username').should('exist') cy.get('#login-username').should('exist')
@ -40,7 +38,7 @@ describe('Login actions', () => {
// User should not be logged in automatically // User should not be logged in automatically
cy.log('**User should not be logged in automatically**') cy.log('**User should not be logged in automatically**')
cy.visit('/') cy.visit('/')
cy.location('pathname').should('eq', '/error') cy.location('pathname').should('eq', '/login')
// Can log in registered user // Can log in registered user
cy.log('**Can log in registered user**') cy.log('**Can log in registered user**')

View file

@ -181,10 +181,13 @@
"ShareBoard.tokenRegenrated": "Token regenerated", "ShareBoard.tokenRegenrated": "Token regenerated",
"ShareBoard.userPermissionsRemoveMemberText": "Remove member", "ShareBoard.userPermissionsRemoveMemberText": "Remove member",
"ShareBoard.userPermissionsYouText": "(You)", "ShareBoard.userPermissionsYouText": "(You)",
"ShareTemplate.Title": "Share Template",
"Sidebar.about": "About Focalboard", "Sidebar.about": "About Focalboard",
"Sidebar.add-board": "+ Add board", "Sidebar.add-board": "+ Add board",
"Sidebar.changePassword": "Change password", "Sidebar.changePassword": "Change password",
"Sidebar.delete-board": "Delete board", "Sidebar.delete-board": "Delete board",
"Sidebar.duplicate-board": "Duplicate board",
"Sidebar.template-from-board": "New template from board",
"Sidebar.export-archive": "Export archive", "Sidebar.export-archive": "Export archive",
"Sidebar.import": "Import", "Sidebar.import": "Import",
"Sidebar.import-archive": "Import archive", "Sidebar.import-archive": "Import archive",

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

@ -0,0 +1 @@
{}

View file

@ -127,6 +127,8 @@
"FindBoFindBoardsDialog.IntroText": "Zoeken naar borden", "FindBoFindBoardsDialog.IntroText": "Zoeken naar borden",
"FindBoardsDialog.NoResultsFor": "Geen resultaten voor \"{searchQuery}\"", "FindBoardsDialog.NoResultsFor": "Geen resultaten voor \"{searchQuery}\"",
"FindBoardsDialog.NoResultsSubtext": "Controleer de spelling of probeer een andere zoekopdracht.", "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.copiedLink": "Gekopieerd!",
"GalleryCard.copyLink": "Kopieer link", "GalleryCard.copyLink": "Kopieer link",
"GalleryCard.delete": "Verwijderen", "GalleryCard.delete": "Verwijderen",
@ -189,7 +191,10 @@
"ShareBoard.copiedLink": "Gekopieerd!", "ShareBoard.copiedLink": "Gekopieerd!",
"ShareBoard.copyLink": "Link kopiëren", "ShareBoard.copyLink": "Link kopiëren",
"ShareBoard.regenerate": "Token opnieuw genereren", "ShareBoard.regenerate": "Token opnieuw genereren",
"ShareBoard.teamPermissionsText": "Iedereen van team {teamName}",
"ShareBoard.tokenRegenrated": "Token opnieuw gegenereerd", "ShareBoard.tokenRegenrated": "Token opnieuw gegenereerd",
"ShareBoard.userPermissionsRemoveMemberText": "Lid verwijderen",
"ShareBoard.userPermissionsYouText": "(jij)",
"Sidebar.about": "Over Focalboard", "Sidebar.about": "Over Focalboard",
"Sidebar.add-board": "+ Bord toevoegen", "Sidebar.add-board": "+ Bord toevoegen",
"Sidebar.changePassword": "Wachtwoord wijzigen", "Sidebar.changePassword": "Wachtwoord wijzigen",
@ -199,11 +204,18 @@
"Sidebar.import-archive": "Archief importeren", "Sidebar.import-archive": "Archief importeren",
"Sidebar.invite-users": "Gebruikers uitnodigen", "Sidebar.invite-users": "Gebruikers uitnodigen",
"Sidebar.logout": "Afmelden", "Sidebar.logout": "Afmelden",
"Sidebar.no-boards-in-category": "Geen boards hier",
"Sidebar.random-icons": "Willekeurige iconen", "Sidebar.random-icons": "Willekeurige iconen",
"Sidebar.set-language": "Taal instellen", "Sidebar.set-language": "Taal instellen",
"Sidebar.set-theme": "Thema instellen", "Sidebar.set-theme": "Thema instellen",
"Sidebar.settings": "Instellingen", "Sidebar.settings": "Instellingen",
"Sidebar.untitled-board": "(Titelloze bord )", "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.add-icon": "Pictogram toevoegen",
"TableComponent.name": "Naam", "TableComponent.name": "Naam",
"TableComponent.plus-new": "+ Nieuw", "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.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.Explore.Button": "Start een rondleiding",
"WelcomePage.Heading": "Welkom bij Boards", "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.", "Workspace.editing-board-template": "Je bent een bordsjabloon aan het bewerken.",
"calendar.month": "Maand", "calendar.month": "Maand",
"calendar.today": "VANDAAG", "calendar.today": "VANDAAG",
@ -281,6 +293,7 @@
"login.register-button": "of maak een account aan als je er nog geen hebt", "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.login-button": "of meldt je aan als je al een account hebt",
"register.signup-title": "Maak een nieuw account", "register.signup-title": "Maak een nieuw account",
"shareBoard.lastAdmin": "Besturen moeten ten minste één beheerder hebben",
"tutorial_tip.finish_tour": "Klaar", "tutorial_tip.finish_tour": "Klaar",
"tutorial_tip.got_it": "Begrepen", "tutorial_tip.got_it": "Begrepen",
"tutorial_tip.ok": "Volgende", "tutorial_tip.ok": "Volgende",

View file

@ -103,7 +103,7 @@
"KanbanCard.copiedLink": "Kopierad!", "KanbanCard.copiedLink": "Kopierad!",
"KanbanCard.copyLink": "Kopiera länk", "KanbanCard.copyLink": "Kopiera länk",
"KanbanCard.delete": "Radera", "KanbanCard.delete": "Radera",
"KanbanCard.duplicate": "Radera", "KanbanCard.duplicate": "Kopiera",
"KanbanCard.untitled": "Saknar titel", "KanbanCard.untitled": "Saknar titel",
"Mutator.new-card-from-template": "nytt kort från mall", "Mutator.new-card-from-template": "nytt kort från mall",
"Mutator.new-template-from-card": "ny mall från kort", "Mutator.new-template-from-card": "ny mall från kort",
@ -201,7 +201,7 @@
"ViewTitle.show-description": "visa beskrivning", "ViewTitle.show-description": "visa beskrivning",
"ViewTitle.untitled-board": "Tavla saknar titel", "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.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", "WelcomePage.Heading": "Välkommen till Anslagstavlan",
"Workspace.editing-board-template": "Du redigerar en tavelmall.", "Workspace.editing-board-template": "Du redigerar en tavelmall.",
"calendar.month": "Månad", "calendar.month": "Månad",

View file

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

View file

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

View file

@ -231,7 +231,6 @@ describe('components/boardTemplateSelector/boardTemplateSelector', () => {
const editIcon = screen.getByText(template1Title).parentElement?.querySelector('.EditIcon') const editIcon = screen.getByText(template1Title).parentElement?.querySelector('.EditIcon')
expect(editIcon).not.toBeNull() expect(editIcon).not.toBeNull()
userEvent.click(editIcon!) userEvent.click(editIcon!)
expect(history.push).toBeCalledTimes(1)
}) })
test('return BoardTemplateSelector and click to add board from template', async () => { test('return BoardTemplateSelector and click to add board from template', async () => {
render(wrapDNDIntl( render(wrapDNDIntl(

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useEffect, useState, useCallback, useMemo} from 'react' import React, {useEffect, useState, useCallback, useMemo} from 'react'
import {FormattedMessage, useIntl} from 'react-intl' 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 {Board} from '../../blocks/board'
import IconButton from '../../widgets/buttons/iconButton' import IconButton from '../../widgets/buttons/iconButton'
@ -23,6 +23,10 @@ import {IUser, UserConfigPatch, UserPropPrefix} from '../../user'
import {getMe, patchProps} from '../../store/users' import {getMe, patchProps} from '../../store/users'
import {BaseTourSteps, TOUR_BASE} from '../onboardingTour' import {BaseTourSteps, TOUR_BASE} from '../onboardingTour'
import {Utils} from "../../utils"
import {Constants} from "../../constants"
import BoardTemplateSelectorPreview from './boardTemplateSelectorPreview' import BoardTemplateSelectorPreview from './boardTemplateSelectorPreview'
import BoardTemplateSelectorItem from './boardTemplateSelectorItem' import BoardTemplateSelectorItem from './boardTemplateSelectorItem'
@ -44,17 +48,14 @@ const BoardTemplateSelector = (props: Props) => {
const me = useAppSelector<IUser|null>(getMe) const me = useAppSelector<IUser|null>(getMe)
const showBoard = useCallback(async (boardId) => { const showBoard = useCallback(async (boardId) => {
const params = {...match.params, boardId: boardId || ''} Utils.showBoard(boardId, match, history)
delete params.viewId
const newPath = generatePath(match.path, params)
history.push(newPath)
if (onClose) { if (onClose) {
onClose() onClose()
} }
}, [match, history, onClose]) }, [match, history, onClose])
useEffect(() => { useEffect(() => {
if (octoClient.teamId !== '0' && globalTemplates.length === 0) { if (octoClient.teamId !== Constants.globalTeamId && globalTemplates.length === 0) {
dispatch(fetchGlobalTemplates()) dispatch(fetchGlobalTemplates())
} }
}, [octoClient.teamId]) }, [octoClient.teamId])
@ -96,7 +97,7 @@ const BoardTemplateSelector = (props: Props) => {
} }
const handleUseTemplate = async () => { 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) { if (activeTemplate.title === OnboardingBoardTitle) {
resetTour() resetTour()
} }

View file

@ -10,6 +10,7 @@ import EditIcon from '../../widgets/icons/edit'
import DeleteBoardDialog from '../sidebar/deleteBoardDialog' import DeleteBoardDialog from '../sidebar/deleteBoardDialog'
import './boardTemplateSelectorItem.scss' import './boardTemplateSelectorItem.scss'
import {Constants} from "../../constants"
type Props = { type Props = {
isActive: boolean isActive: boolean
@ -38,7 +39,9 @@ const BoardTemplateSelectorItem = (props: Props) => {
> >
<span className='template-icon'>{template.icon}</span> <span className='template-icon'>{template.icon}</span>
<span className='template-name'>{template.title}</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'> <div className='actions'>
<IconButton <IconButton
icon={<DeleteIcon/>} icon={<DeleteIcon/>}

View file

@ -38,6 +38,7 @@ import {UserConfigPatch} from '../user'
import octoClient from '../octoClient' import octoClient from '../octoClient'
import ShareBoardButton from './shareBoard/shareBoardButton' import ShareBoardButton from './shareBoard/shareBoardButton'
import ShareBoardLoginButton from './shareBoard/shareBoardLoginButton'
import CardDialog from './cardDialog' import CardDialog from './cardDialog'
import RootPortal from './rootPortal' import RootPortal from './rootPortal'
@ -334,6 +335,9 @@ const CenterPanel = (props: Props) => {
e.stopPropagation() e.stopPropagation()
}, [selectedCardIds, props.activeView, props.cards, showCard]) }, [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 {groupByProperty, activeView, board, views, cards} = props
const {visible: visibleGroups, hidden: hiddenGroups} = useMemo( const {visible: visibleGroups, hidden: hiddenGroups} = useMemo(
() => getVisibleAndHiddenGroups(cards, activeView.fields.visibleOptionIds, activeView.fields.hiddenOptionIds, groupByProperty), () => getVisibleAndHiddenGroups(cards, activeView.fields.visibleOptionIds, activeView.fields.hiddenOptionIds, groupByProperty),
@ -369,13 +373,14 @@ const CenterPanel = (props: Props) => {
readonly={props.readonly} readonly={props.readonly}
/> />
<div className='shareButtonWrapper'> <div className='shareButtonWrapper'>
{!props.readonly && {showShareButton &&
( <ShareBoardButton
<ShareBoardButton boardId={props.board.id}
boardId={props.board.id} enableSharedBoards={props.clientConfig?.enablePublicSharedBoards || false}
enableSharedBoards={props.clientConfig?.enablePublicSharedBoards || false} />
/> }
) {showShareLoginButton &&
<ShareBoardLoginButton/>
} }
<ShareBoardTourStep/> <ShareBoardTourStep/>
</div> </div>

View file

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

View file

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

View file

@ -9,7 +9,7 @@ import Select from 'react-select/async'
import {CSSObject} from '@emotion/serialize' import {CSSObject} from '@emotion/serialize'
import {useAppSelector} from '../../store/hooks' import {useAppSelector} from '../../store/hooks'
import {getCurrentBoardId, getCurrentBoardMembers} from '../../store/boards' import {getCurrentBoard, getCurrentBoardMembers} from '../../store/boards'
import {getMe, getBoardUsersList} from '../../store/users' import {getMe, getBoardUsersList} from '../../store/users'
import {Utils, IDType} from '../../utils' import {Utils, IDType} from '../../utils'
@ -95,7 +95,8 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
// members of the current board // members of the current board
const members = useAppSelector<{[key: string]: BoardMember}>(getCurrentBoardMembers) 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 boardUsers = useAppSelector<IUser[]>(getBoardUsersList)
const me = useAppSelector<IUser|null>(getMe) 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'> <span className='text-heading5'>
<FormattedMessage <FormattedMessage
id={'ShareBoard.Title'} id={'ShareBoard.Title'}
@ -248,6 +249,17 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
</span> </span>
) )
const shareTemplateTitle = (
<span className='text-heading5'>
<FormattedMessage
id={'ShareTemplate.Title'}
defaultMessage={'Share Template'}
/>
</span>
)
const toolbar = board.isTemplate ? shareTemplateTitle : shareBoardTitle
return ( return (
<Dialog <Dialog
onClose={props.onClose} onClose={props.onClose}
@ -299,7 +311,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
})} })}
</div> </div>
{props.enableSharedBoards && ( {props.enableSharedBoards && !board.isTemplate && (
<div className='tabs-container'> <div className='tabs-container'>
<button <button
onClick={() => setPublish(false)} onClick={() => setPublish(false)}
@ -323,7 +335,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
</BoardPermissionGate> </BoardPermissionGate>
</div> </div>
)} )}
{(props.enableSharedBoards && publish) && {(props.enableSharedBoards && publish && !board.isTemplate) &&
(<BoardPermissionGate permissions={[Permission.ShareBoard]}> (<BoardPermissionGate permissions={[Permission.ShareBoard]}>
<div className='tabs-content'> <div className='tabs-content'>
<div> <div>
@ -401,7 +413,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
</BoardPermissionGate> </BoardPermissionGate>
)} )}
{!publish && ( {!publish && !board.isTemplate && (
<div className='tabs-content'> <div className='tabs-content'>
<div> <div>
<div className='d-flex justify-content-between'> <div className='d-flex justify-content-between'>

View file

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

View file

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render} from '@testing-library/react'
import React from 'react'
import {TestBlockFactory} from '../../test/testBlockFactory'
import {wrapDNDIntl} from '../../testUtils'
import ShareBoardLoginButton from './shareBoardLoginButton'
jest.useFakeTimers()
const boardId = '1'
const board = TestBlockFactory.createBoard()
board.id = boardId
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom')
return {
...originalModule,
useRouteMatch: jest.fn(() => {
return {
teamId: 'team1',
boardId: 'boardId1',
viewId: 'viewId1',
cardId: 'cardId1',
}
}),
}
})
describe('src/components/shareBoard/shareBoardLoginButton', () => {
const savedLocation = window.location
afterEach(() => {
window.location = savedLocation
})
test('should match snapshot', async () => {
// delete window.location
window.location = Object.assign(new URL('https://example.org/mattermost'))
const result = render(
wrapDNDIntl(
<ShareBoardLoginButton/>,
))
const renderer = result.container
expect(renderer).toMatchSnapshot()
})
})

View file

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react'
import {FormattedMessage} from 'react-intl'
import {generatePath, useRouteMatch, useHistory} from 'react-router-dom'
import Button from '../../widgets/buttons/button'
import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../telemetry/telemetryClient'
import {Utils} from '../../utils'
import './shareBoardLoginButton.scss'
const ShareBoardLoginButton = () => {
const match = useRouteMatch<{teamId: string, boardId: string, viewId?: string, cardId?: string}>()
const history = useHistory()
let redirectQueryParam = 'r=' + encodeURIComponent(generatePath('/:boardId?/:viewId?/:cardId?', match.params))
if (Utils.isFocalboardLegacy()) {
redirectQueryParam = 'redirect_to=' + encodeURIComponent(generatePath('/boards/team/:teamId/:boardId?/:viewId?/:cardId?', match.params))
}
const loginPath = '/login?' + redirectQueryParam
const onLoginClick = useCallback(() => {
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ShareBoardLogin)
if (Utils.isFocalboardLegacy()) {
location.assign(loginPath)
} else {
history.push(loginPath)
}
}, [])
return (
<div className='ShareBoardLoginButton'>
<Button
title='Login'
size='medium'
emphasis='primary'
onClick={() => onLoginClick()}
>
<FormattedMessage
id='CenterPanel.Login'
defaultMessage='Login'
/>
</Button>
</div>
)
}
export default React.memo(ShareBoardLoginButton)

View file

@ -29,6 +29,8 @@ import wsClient, {WSClient} from '../../wsclient'
import {getCurrentTeam} from '../../store/teams' import {getCurrentTeam} from '../../store/teams'
import {Constants} from "../../constants"
import SidebarCategory from './sidebarCategory' import SidebarCategory from './sidebarCategory'
import SidebarSettingsMenu from './sidebarSettingsMenu' import SidebarSettingsMenu from './sidebarSettingsMenu'
import SidebarUserMenu from './sidebarUserMenu' import SidebarUserMenu from './sidebarUserMenu'
@ -152,7 +154,7 @@ const Sidebar = (props: Props) => {
</div> </div>
</div>} </div>}
{team && team.id !== '0' && {team && team.id !== Constants.globalTeamId &&
<div className='WorkspaceTitle'> <div className='WorkspaceTitle'>
{Utils.isFocalboardPlugin() && {Utils.isFocalboardPlugin() &&
<> <>

View file

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useState} from 'react' import React, {useCallback, useState} from 'react'
import {useIntl} from 'react-intl' import {useIntl} from 'react-intl'
import {useHistory, useRouteMatch} from "react-router-dom"
import {Board} from '../../blocks/board' import {Board} from '../../blocks/board'
import {BoardView, IViewType} from '../../blocks/boardView' import {BoardView, IViewType} from '../../blocks/boardView'
@ -27,6 +28,10 @@ import CalendarIcon from '../../widgets/icons/calendar'
import {getCurrentTeam} from '../../store/teams' import {getCurrentTeam} from '../../store/teams'
import {Permission} from '../../constants' 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 => { const iconForViewType = (viewType: IViewType): JSX.Element => {
switch (viewType) { switch (viewType) {
@ -58,6 +63,9 @@ const SidebarBoardItem = (props: Props) => {
const currentViewId = useAppSelector(getCurrentViewId) const currentViewId = useAppSelector(getCurrentViewId)
const teamID = team?.id || '' const teamID = team?.id || ''
const match = useRouteMatch<{boardId: string, viewId?: string, cardId?: string, teamId?: string}>()
const history = useHistory()
const generateMoveToCategoryOptions = (blockID: string) => { const generateMoveToCategoryOptions = (blockID: string) => {
return props.allCategories.map((category) => ( return props.allCategories.map((category) => (
<Menu.Text <Menu.Text
@ -74,6 +82,28 @@ const SidebarBoardItem = (props: Props) => {
} }
const board = props.board 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)'}) const title = board.title || intl.formatMessage({id: 'Sidebar.untitled-board', defaultMessage: '(Untitled Board)'})
return ( return (
<> <>
@ -129,6 +159,18 @@ const SidebarBoardItem = (props: Props) => {
> >
{generateMoveToCategoryOptions(board.id)} {generateMoveToCategoryOptions(board.id)}
</Menu.SubMenu> </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> </Menu>
</MenuWrapper> </MenuWrapper>
</div> </div>

View file

@ -60,15 +60,7 @@ const SidebarCategory = (props: Props) => {
const teamID = team?.id || '' const teamID = team?.id || ''
const showBoard = useCallback((boardId) => { const showBoard = useCallback((boardId) => {
// if the same board, reuse the match params Utils.showBoard(boardId, match, history)
// 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)
props.hideSidebar() props.hideSidebar()
}, [match, history]) }, [match, history])

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import {Category, CategoryBlocks} from './store/sidebar'
import {Team} from './store/teams' import {Team} from './store/teams'
import {Subscription} from './wsclient' import {Subscription} from './wsclient'
import {PrepareOnboardingResponse} from './onboardingTour' import {PrepareOnboardingResponse} from './onboardingTour'
import {Constants} from "./constants"
// //
// OctoClient is the client interface to the server APIs // OctoClient is the client interface to the server APIs
@ -45,7 +46,7 @@ class OctoClient {
localStorage.setItem('focalboardSessionId', value) localStorage.setItem('focalboardSessionId', value)
} }
constructor(serverUrl?: string, public teamId = '0') { constructor(serverUrl?: string, public teamId = Constants.globalTeamId) {
this.serverUrl = serverUrl this.serverUrl = serverUrl
} }
@ -144,7 +145,7 @@ class OctoClient {
private teamPath(teamId?: string): string { private teamPath(teamId?: string): string {
let teamIdToUse = teamId let teamIdToUse = teamId
if (!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}` return `/api/v1/teams/${teamIdToUse}`
@ -200,20 +201,6 @@ class OctoClient {
return (await this.getJson(response, {})) as Record<string, string> 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 // If no boardID is provided, it will export the entire archive
async exportArchive(boardID = ''): Promise<Response> { async exportArchive(boardID = ''): Promise<Response> {
const path = `/api/v1/boards/${boardID}/archive/export` const path = `/api/v1/boards/${boardID}/archive/export`
@ -349,7 +336,6 @@ class OctoClient {
async followBlock(blockId: string, blockType: string, userId: string): Promise<Response> { async followBlock(blockId: string, blockType: string, userId: string): Promise<Response> {
const body: Subscription = { const body: Subscription = {
teamId: this.teamId,
blockType, blockType,
blockId, blockId,
subscriberType: 'user', subscriberType: 'user',

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useState} from 'react' 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 {FormattedMessage} from 'react-intl'
import {useAppDispatch} from '../store/hooks' import {useAppDispatch} from '../store/hooks'
@ -15,14 +16,19 @@ const LoginPage = () => {
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const history = useHistory()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const queryParams = new URLSearchParams(useLocation().search)
const history = useHistory()
const handleLogin = async (): Promise<void> => { const handleLogin = async (): Promise<void> => {
const logged = await client.login(username, password) const logged = await client.login(username, password)
if (logged) { if (logged) {
await dispatch(fetchMe()) await dispatch(fetchMe())
history.push('/') if (queryParams) {
history.push(queryParams.get('r') || '/')
} else {
history.push('/')
}
} else { } else {
setErrorMessage('Login failed') setErrorMessage('Login failed')
} }

View file

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

View file

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

View file

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

View file

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

View file

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