Unfurl Focalboard Link (#1081)

This commit is contained in:
Hossein 2021-10-22 12:13:31 -04:00 committed by GitHub
parent 8666bc833a
commit eed1f86c15
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 997 additions and 15 deletions

View file

@ -7,7 +7,7 @@ replace github.com/mattermost/focalboard/server => ../server
require ( require (
github.com/mattermost/focalboard/server v0.0.0-20210525112228-f43e4028dbdc github.com/mattermost/focalboard/server v0.0.0-20210525112228-f43e4028dbdc
github.com/mattermost/mattermost-plugin-api v0.0.20 github.com/mattermost/mattermost-plugin-api v0.0.20
github.com/mattermost/mattermost-server/v6 v6.0.0-20210913141218-bb659d03fde0 github.com/mattermost/mattermost-server/v6 v6.0.0-20211013145127-6521b0dfe3e5
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.7.0 github.com/stretchr/testify v1.7.0
) )

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,12 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"path" "path"
"strings"
"sync" "sync"
"github.com/mattermost/focalboard/server/auth" "github.com/mattermost/focalboard/server/auth"
@ -19,9 +22,19 @@ import (
mmModel "github.com/mattermost/mattermost-server/v6/model" mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin" "github.com/mattermost/mattermost-server/v6/plugin"
"github.com/mattermost/mattermost-server/v6/shared/markdown"
"github.com/mattermost/mattermost-server/v6/shared/mlog" "github.com/mattermost/mattermost-server/v6/shared/mlog"
) )
type BoardsEmbed struct {
OriginalPath string `json:"originalPath"`
WorkspaceID string `json:"workspaceID"`
ViewID string `json:"viewID"`
BoardID string `json:"boardID"`
CardID string `json:"cardID"`
ReadToken string `json:"readToken,omitempty"`
}
// Plugin implements the interface expected by the Mattermost server to communicate between the server and plugin processes. // Plugin implements the interface expected by the Mattermost server to communicate between the server and plugin processes.
type Plugin struct { type Plugin struct {
plugin.MattermostPlugin plugin.MattermostPlugin
@ -219,3 +232,130 @@ func defaultLoggingConfig() string {
} }
}` }`
} }
func (p *Plugin) MessageWillBePosted(_ *plugin.Context, post *mmModel.Post) (*mmModel.Post, string) {
return postWithBoardsEmbed(post, p.API.GetConfig().FeatureFlags.BoardsUnfurl), ""
}
func (p *Plugin) MessageWillBeUpdated(_ *plugin.Context, newPost, _ *mmModel.Post) (*mmModel.Post, string) {
return postWithBoardsEmbed(newPost, p.API.GetConfig().FeatureFlags.BoardsUnfurl), ""
}
func postWithBoardsEmbed(post *mmModel.Post, showBoardsUnfurl bool) *mmModel.Post {
if _, ok := post.GetProps()["boards"]; ok {
post.AddProp("boards", nil)
}
if !showBoardsUnfurl {
return post
}
firstLink := getFirstLink(post.Message)
if firstLink == "" {
return post
}
u, err := url.Parse(firstLink)
if err != nil {
return post
}
// Trim away the first / because otherwise after we split the string, the first element in the array is a empty element
urlPath := u.Path
if strings.HasPrefix(urlPath, "/") {
urlPath = u.Path[1:]
}
pathSplit := strings.Split(strings.ToLower(urlPath), "/")
queryParams := u.Query()
if len(pathSplit) == 0 {
return post
}
workspaceID, boardID, viewID, cardID := returnBoardsParams(pathSplit)
if workspaceID != "" && boardID != "" && viewID != "" && cardID != "" {
b, _ := json.Marshal(BoardsEmbed{
WorkspaceID: workspaceID,
BoardID: boardID,
ViewID: viewID,
CardID: cardID,
ReadToken: queryParams.Get("r"),
OriginalPath: u.RequestURI(),
})
BoardsPostEmbed := &mmModel.PostEmbed{
Type: mmModel.PostEmbedBoards,
Data: string(b),
}
if post.Metadata == nil {
post.Metadata = &mmModel.PostMetadata{}
}
post.Metadata.Embeds = []*mmModel.PostEmbed{BoardsPostEmbed}
post.AddProp("boards", string(b))
}
return post
}
func getFirstLink(str string) string {
firstLink := ""
markdown.Inspect(str, func(blockOrInline interface{}) bool {
if _, ok := blockOrInline.(*markdown.Autolink); ok {
if link := blockOrInline.(*markdown.Autolink).Destination(); firstLink == "" {
firstLink = link
}
}
return true
})
return firstLink
}
func returnBoardsParams(pathArray []string) (workspaceID, boardID, viewID, cardID string) {
// The reason we are doing this search for the first instance of boards or plugins is to take into account URL subpaths
index := -1
for i := 0; i < len(pathArray); i++ {
if pathArray[i] == "boards" || pathArray[i] == "plugins" {
index = i
break
}
}
if index == -1 {
return workspaceID, boardID, viewID, cardID
}
// If at index, the parameter in the path is boards,
// then we've copied this directly as logged in user of that board
// If at index, the parameter in the path is plugins,
// then we've copied this from a shared board
// For card links copied on a non-shared board, the path looks like {...Mattermost Url}.../boards/workspace/workspaceID/boardID/viewID/cardID
// For card links copied on a shared board, the path looks like
// {...Mattermost Url}.../plugins/focalboard/workspace/workspaceID/shared/boardID/viewID/cardID?r=read_token
// This is a non-shared board card link
if len(pathArray)-index == 6 && pathArray[index] == "boards" && pathArray[index+1] == "workspace" {
workspaceID = pathArray[index+2]
boardID = pathArray[index+3]
viewID = pathArray[index+4]
cardID = pathArray[index+5]
} else if len(pathArray)-index == 8 && pathArray[index] == "plugins" &&
pathArray[index+1] == "focalboard" &&
pathArray[index+2] == "workspace" &&
pathArray[index+4] == "shared" { // This is a shared board card link
workspaceID = pathArray[index+3]
boardID = pathArray[index+5]
viewID = pathArray[index+6]
cardID = pathArray[index+7]
}
return workspaceID, boardID, viewID, cardID
}

View file

@ -5,7 +5,8 @@ function blockList(line) {
return line.startsWith('.focalboard-body') || return line.startsWith('.focalboard-body') ||
line.startsWith('.GlobalHeaderComponent') || line.startsWith('.GlobalHeaderComponent') ||
line.startsWith('.boards-rhs-icon') || line.startsWith('.boards-rhs-icon') ||
line.startsWith('.focalboard-plugin-root'); line.startsWith('.focalboard-plugin-root') ||
line.startsWith('.FocalboardUnfurl');
} }
module.exports = function loader(source) { module.exports = function loader(source) {

View file

@ -6,6 +6,7 @@
"": { "": {
"dependencies": { "dependencies": {
"core-js": "3.12.1", "core-js": "3.12.1",
"marked": ">=2.0.1",
"mattermost-redux": "5.33.1", "mattermost-redux": "5.33.1",
"react-intl": "^5.13.5", "react-intl": "^5.13.5",
"react-router-dom": "5.2.0" "react-router-dom": "5.2.0"
@ -12235,6 +12236,17 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/marked": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/marked/-/marked-3.0.6.tgz",
"integrity": "sha512-a1hY8eqdP9JgmsaO0MYYhO9Li2nfY/5pAj+gWU5r41Lze6AV4Xty1cseLWDcOYimJnaVfQAomaA6NK+z2IyR+w==",
"bin": {
"marked": "bin/marked"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/mattermost-redux": { "node_modules/mattermost-redux": {
"version": "5.33.1", "version": "5.33.1",
"resolved": "https://registry.npmjs.org/mattermost-redux/-/mattermost-redux-5.33.1.tgz", "resolved": "https://registry.npmjs.org/mattermost-redux/-/mattermost-redux-5.33.1.tgz",
@ -27341,6 +27353,11 @@
"object-visit": "^1.0.0" "object-visit": "^1.0.0"
} }
}, },
"marked": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/marked/-/marked-3.0.6.tgz",
"integrity": "sha512-a1hY8eqdP9JgmsaO0MYYhO9Li2nfY/5pAj+gWU5r41Lze6AV4Xty1cseLWDcOYimJnaVfQAomaA6NK+z2IyR+w=="
},
"mattermost-redux": { "mattermost-redux": {
"version": "5.33.1", "version": "5.33.1",
"resolved": "https://registry.npmjs.org/mattermost-redux/-/mattermost-redux-5.33.1.tgz", "resolved": "https://registry.npmjs.org/mattermost-redux/-/mattermost-redux-5.33.1.tgz",

View file

@ -75,6 +75,7 @@
}, },
"dependencies": { "dependencies": {
"core-js": "3.12.1", "core-js": "3.12.1",
"marked": ">=2.0.1",
"mattermost-redux": "5.33.1", "mattermost-redux": "5.33.1",
"react-intl": "^5.13.5", "react-intl": "^5.13.5",
"react-router-dom": "5.2.0" "react-router-dom": "5.2.0"

View file

@ -0,0 +1,131 @@
.FocalboardUnfurl {
display: block;
padding: 16px;
border: 1px solid rgba(61, 60, 64, 0.24) !important;
border-radius: 4px;
box-sizing: border-box;
box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.08);
width: 425px;
text-decoration: none !important;
color: inherit !important;
&:hover {
cursor: pointer;
}
.header {
display: flex;
align-items: center;
.icon {
font-size: 36px;
}
.information {
display: flex;
flex-direction: column;
margin-left: 10px;
overflow: hidden;
.card_title {
color: #3D3C40;
font-weight: 600;
font-size: 14px;
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.board_title {
color: rgba(61, 60, 64, 0.56);
font-size: 12px;
line-height: 16px;
}
}
}
.body {
border: 1px solid rgba(61, 60, 64, 0.16);
border-radius: 4px;
margin-top: 16px;
height: 145px;
overflow: hidden;
padding: 16px;
white-space: nowrap;
}
.footer {
display: flex;
align-items: center;
height: 40px;
margin-top: 16px;
.timestamp_properties {
margin-left: 12px;
max-width: 90%;
.properties {
display: flex;
align-items: center;
white-space: nowrap;
.remainder {
color: rgba(61, 60, 64, 0.48);
font-weight: bold;
font-size: 14px;
}
.property {
border-radius: 4px;
padding: 2px 4px;
margin-right: 10px;
max-width: 33%;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: normal;
&.propColorDefault {
background-color: var(--prop-default);
}
&.propColorGray {
background-color: var(--prop-gray);
}
&.propColorBrown {
background-color: var(--prop-brown);
}
&.propColorOrange {
background-color: var(--prop-orange);
}
&.propColorYellow {
background-color: var(--prop-yellow);
}
&.propColorGreen {
background-color: var(--prop-green);
}
&.propColorBlue {
background-color: var(--prop-blue);
}
&.propColorPurple {
background-color: var(--prop-purple);
}
&.propColorPink {
background-color: var(--prop-pink);
}
&.propColorRed {
background-color: var(--prop-red);
}
}
}
}
}
}

View file

@ -0,0 +1,217 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react'
import {IntlProvider, FormattedMessage} from 'react-intl'
import {connect} from 'react-redux'
import {GlobalState} from 'mattermost-redux/types/store'
import {getCurrentUserLocale} from 'mattermost-redux/selectors/entities/i18n'
import {getMessages} from './../../../../../webapp/src/i18n'
import {Utils} from './../../../../../webapp/src/utils'
import {Card} from './../../../../../webapp/src/blocks/card'
import {Board} from './../../../../../webapp/src/blocks/board'
import {ContentBlock} from './../../../../../webapp/src/blocks/contentBlock'
import octoClient from './../../../../../webapp/src/octoClient'
const Avatar = (window as any).Components.Avatar
const Timestamp = (window as any).Components.Timestamp
const imageURLForUser = (window as any).Components.imageURLForUser
import './boardsUnfurl.scss'
type Props = {
embed: {
data: string,
},
locale: string,
}
function mapStateToProps(state: GlobalState) {
const locale = getCurrentUserLocale(state)
return {
locale,
}
}
const BoardsUnfurl = (props: Props): JSX.Element => {
if (!props.embed || !props.embed.data) {
return <></>
}
const {embed, locale} = props
const focalboardInformation = JSON.parse(embed.data)
const {workspaceID, cardID, boardID, readToken, originalPath} = focalboardInformation
const baseURL = window.location.origin
if (!workspaceID || !cardID || !boardID) {
return <></>
}
const [card, setCard] = useState<Card>()
const [content, setContent] = useState<ContentBlock>()
const [board, setBoard] = useState<Board>()
useEffect(() => {
const fetchData = async () => {
const [cards, boards] = await Promise.all(
[
octoClient.getBlocksWithBlockID(cardID, workspaceID, readToken),
octoClient.getBlocksWithBlockID(boardID, workspaceID, readToken),
],
)
const [firstCard] = cards as Card[]
const [firstBoard] = boards as Board[]
if (!firstCard || !firstBoard) {
return null
}
setCard(firstCard)
setBoard(firstBoard)
if (firstCard.fields.contentOrder.length) {
let [firstContentBlockID] = firstCard.fields?.contentOrder
if (Array.isArray(firstContentBlockID)) {
[firstContentBlockID] = firstContentBlockID
}
const contentBlock = await octoClient.getBlocksWithBlockID(firstContentBlockID, workspaceID, readToken) as ContentBlock[]
const [firstContentBlock] = contentBlock
if (!firstContentBlock) {
return null
}
setContent(firstContentBlock)
}
return null
}
fetchData()
}, [originalPath])
if (!card || !board) {
return <></>
}
const propertyKeyArray = Object.keys(card.fields.properties)
const propertyValueArray = Object.values(card.fields.properties)
const options = board.fields.cardProperties
const propertiesToDisplay: Array<Record<string, string>> = []
// We will just display the first 3 or less select/multi-select properties and do a +n for remainder if any remainder
if (propertyKeyArray.length > 0) {
for (let i = 0; i < propertyKeyArray.length && propertiesToDisplay.length < 3; i++) {
const keyToLookUp = propertyKeyArray[i]
const correspondingOption = options.find((option) => option.id === keyToLookUp)
if (!correspondingOption) {
continue
}
let valueToLookUp = propertyValueArray[i]
if (Array.isArray(valueToLookUp)) {
valueToLookUp = valueToLookUp[0]
}
const optionSelected = correspondingOption.options.find((option) => option.id === valueToLookUp)
if (!optionSelected) {
continue
}
propertiesToDisplay.push({optionName: correspondingOption.name, optionValue: optionSelected.value, optionValueColour: optionSelected.color})
}
}
const remainder = propertyKeyArray.length - propertiesToDisplay.length
const html: string = Utils.htmlFromMarkdown(content?.title || '')
return (
<IntlProvider
messages={getMessages(locale)}
locale={locale}
>
<a
className='FocalboardUnfurl'
href={`${baseURL}${originalPath}`}
rel='noopener noreferrer'
target='_blank'
>
{/* Header of the Card*/}
<div className='header'>
<span className='icon'>{card.fields?.icon}</span>
<div className='information'>
<span className='card_title'>{card.title}</span>
<span className='board_title'>{board.title}</span>
</div>
</div>
{/* Body of the Card*/}
{html !== '' &&
<div className='body'>
<div
dangerouslySetInnerHTML={{__html: html}}
/>
</div>
}
{/* Footer of the Card*/}
<div className='footer'>
<div className='avatar'>
<Avatar
size={'md'}
url={imageURLForUser(card.createdBy)}
className={'avatar-post-preview'}
/>
</div>
<div className='timestamp_properties'>
<div className='properties'>
{propertiesToDisplay.map((property) => (
<div
key={property.optionValue}
className={`property ${property.optionValueColour}`}
title={`${property.optionName}`}
>
{property.optionValue}
</div>
))}
{remainder > 0 &&
<span className='remainder'>
<FormattedMessage
id='BoardsUnfurl.Remainder'
defaultMessage='+{remainder} more'
values={{
remainder,
}}
/>
</span>
}
</div>
<span className='post-preview__time'>
<FormattedMessage
id='BoardsUnfurl.Updated'
defaultMessage='Updated {time}'
values={{
time: (
<Timestamp
value={card.updateAt}
units={[
'now',
'minute',
'hour',
'day',
]}
useTime={false}
day={'numeric'}
/>
),
}}
/>
</span>
</div>
</div>
</a>
</IntlProvider>
)
}
export default connect(mapStateToProps)(BoardsUnfurl)

View file

@ -13,6 +13,8 @@ windowAny.baseURL = '/plugins/focalboard'
windowAny.frontendBaseURL = '/boards' windowAny.frontendBaseURL = '/boards'
windowAny.isFocalboardPlugin = true windowAny.isFocalboardPlugin = true
import {ClientConfig} from 'mattermost-redux/types/config'
import App from '../../../webapp/src/app' import App from '../../../webapp/src/app'
import store from '../../../webapp/src/store' import store from '../../../webapp/src/store'
import GlobalHeader from '../../../webapp/src/components/globalHeader/globalHeader' import GlobalHeader from '../../../webapp/src/components/globalHeader/globalHeader'
@ -26,6 +28,7 @@ import '../../../webapp/src/styles/main.scss'
import '../../../webapp/src/styles/labels.scss' import '../../../webapp/src/styles/labels.scss'
import octoClient from '../../../webapp/src/octoClient' import octoClient from '../../../webapp/src/octoClient'
import BoardsUnfurl from './components/boardsUnfurl/boardsUnfurl'
import wsClient, {MMWebSocketClient, ACTION_UPDATE_BLOCK, ACTION_UPDATE_CLIENT_CONFIG} from './../../../webapp/src/wsclient' import wsClient, {MMWebSocketClient, ACTION_UPDATE_BLOCK, ACTION_UPDATE_CLIENT_CONFIG} from './../../../webapp/src/wsclient'
import manifest from './manifest' import manifest from './manifest'
@ -143,6 +146,10 @@ export default class Plugin {
} }
this.channelHeaderButtonId = registry.registerChannelHeaderButtonAction(<FocalboardIcon/>, goToFocalboardWorkspace, '', 'Boards') this.channelHeaderButtonId = registry.registerChannelHeaderButtonAction(<FocalboardIcon/>, goToFocalboardWorkspace, '', 'Boards')
this.registry.registerProduct('/boards', 'product-boards', 'Boards', '/boards/welcome', MainApp, HeaderComponent) this.registry.registerProduct('/boards', 'product-boards', 'Boards', '/boards/welcome', MainApp, HeaderComponent)
if (mmStore.getState().entities.general.config?.['FeatureFlagBoardsUnfurl' as keyof Partial<ClientConfig>] === 'true') {
this.registry.registerPostWillRenderEmbedComponent((embed) => embed.type === 'boards', BoardsUnfurl, false)
}
} else { } else {
windowAny.frontendBaseURL = subpath + '/plug/focalboard' windowAny.frontendBaseURL = subpath + '/plug/focalboard'
this.channelHeaderButtonId = registry.registerChannelHeaderButtonAction(<FocalboardIcon/>, () => { this.channelHeaderButtonId = registry.registerChannelHeaderButtonAction(<FocalboardIcon/>, () => {

View file

@ -7,6 +7,7 @@ export interface PluginRegistry {
registerProductRoute(route: string, component: React.ElementType) registerProductRoute(route: string, component: React.ElementType)
unregisterComponent(componentId: string) unregisterComponent(componentId: string)
registerProduct(baseURL: string, switcherIcon: string, switcherText: string, switcherLinkURL: string, mainComponent: React.ElementType, headerCompoent: React.ElementType) registerProduct(baseURL: string, switcherIcon: string, switcherText: string, switcherLinkURL: string, mainComponent: React.ElementType, headerCompoent: React.ElementType)
registerPostWillRenderEmbedComponent(match: (embed: {type: string, data: any}) => void, component: any, toggleable: boolean)
registerWebSocketEventHandler(event: string, handler: (e: any) => void) registerWebSocketEventHandler(event: string, handler: (e: any) => void)
unregisterWebSocketEventHandler(event: string) unregisterWebSocketEventHandler(event: string)

View file

@ -1 +1,3 @@
declare module "mm-react-router-dom" // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
declare module 'mm-react-router-dom'

View file

@ -2,10 +2,10 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
const exec = require('child_process').exec; const exec = require('child_process').exec;
const webpack = require('webpack');
const path = require('path'); const path = require('path');
const webpack = require('webpack');
const tsTransformer = require('@formatjs/ts-transformer'); const tsTransformer = require('@formatjs/ts-transformer');
const PLUGIN_ID = require('../plugin.json').id; const PLUGIN_ID = require('../plugin.json').id;

View file

@ -247,7 +247,8 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
parentID := query.Get("parent_id") parentID := query.Get("parent_id")
blockType := query.Get("type") blockType := query.Get("type")
all := query.Get("all") all := query.Get("all")
container, err := a.getContainer(r) blockID := query.Get("block_id")
container, err := a.getContainerAllowingReadTokenForBlock(r, blockID)
if err != nil { if err != nil {
a.noContainerErrorResponse(w, r.URL.Path, err) a.noContainerErrorResponse(w, r.URL.Path, err)
return return
@ -258,15 +259,27 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
auditRec.AddMeta("parentID", parentID) auditRec.AddMeta("parentID", parentID)
auditRec.AddMeta("blockType", blockType) auditRec.AddMeta("blockType", blockType)
auditRec.AddMeta("all", all) auditRec.AddMeta("all", all)
auditRec.AddMeta("blockID", blockID)
var blocks []model.Block var blocks []model.Block
if all != "" { var block *model.Block
switch {
case all != "":
blocks, err = a.app.GetAllBlocks(*container) blocks, err = a.app.GetAllBlocks(*container)
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
} }
} else { case blockID != "":
block, err = a.app.GetBlockWithID(*container, blockID)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
if block != nil {
blocks = append(blocks, *block)
}
default:
blocks, err = a.app.GetBlocks(*container, parentID, blockType) blocks, err = a.app.GetBlocks(*container, parentID, blockType)
if err != nil { if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
@ -277,6 +290,7 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
a.logger.Debug("GetBlocks", a.logger.Debug("GetBlocks",
mlog.String("parentID", parentID), mlog.String("parentID", parentID),
mlog.String("blockType", blockType), mlog.String("blockType", blockType),
mlog.String("blockID", blockID),
mlog.Int("block_count", len(blocks)), mlog.Int("block_count", len(blocks)),
) )

View file

@ -20,6 +20,10 @@ func (a *App) GetBlocks(c store.Container, parentID string, blockType string) ([
return a.store.GetBlocksWithParent(c, parentID) return a.store.GetBlocksWithParent(c, parentID)
} }
func (a *App) GetBlockWithID(c store.Container, blockID string) (*model.Block, error) {
return a.store.GetBlock(c, blockID)
}
func (a *App) GetBlocksWithRootID(c store.Container, rootID string) ([]model.Block, error) { func (a *App) GetBlocksWithRootID(c store.Container, rootID string) ([]model.Block, error) {
return a.store.GetBlocksWithRootID(c, rootID) return a.store.GetBlocksWithRootID(c, rootID)
} }
@ -101,6 +105,7 @@ func (a *App) GetSubTree(c store.Container, blockID string, levels int) ([]model
if levels >= 3 { if levels >= 3 {
return a.store.GetSubTree3(c, blockID) return a.store.GetSubTree3(c, blockID)
} }
return a.store.GetSubTree2(c, blockID) return a.store.GetSubTree2(c, blockID)
} }

View file

@ -8,6 +8,8 @@
"BoardComponent.no-property-title": "Items with an empty {property} property will go here. This column cannot be removed.", "BoardComponent.no-property-title": "Items with an empty {property} property will go here. This column cannot be removed.",
"BoardComponent.show": "Show", "BoardComponent.show": "Show",
"BoardPage.syncFailed": "Board may be deleted or access revoked.", "BoardPage.syncFailed": "Board may be deleted or access revoked.",
"BoardsUnfurl.Remainder": "+{remainder} more",
"BoardsUnfurl.Updated": "Updated {time}",
"CardDetail.add-content": "Add content", "CardDetail.add-content": "Add content",
"CardDetail.add-icon": "Add icon", "CardDetail.add-icon": "Add icon",
"CardDetail.add-property": "+ Add a property", "CardDetail.add-property": "+ Add a property",
@ -21,6 +23,8 @@
"CardDialog.nocard": "This card doesn't exist or is inaccessible.", "CardDialog.nocard": "This card doesn't exist or is inaccessible.",
"CardDialog.copiedLink": "Copied!", "CardDialog.copiedLink": "Copied!",
"CardDialog.copyLink": "Copy link", "CardDialog.copyLink": "Copy link",
"CardDialog.editing-template": "You're editing a template.",
"CardDialog.nocard": "This card doesn't exist or is inaccessible.",
"ColorOption.selectColor": "Select {color} Color", "ColorOption.selectColor": "Select {color} Color",
"Comment.delete": "Delete", "Comment.delete": "Delete",
"CommentsList.send": "Send", "CommentsList.send": "Send",
@ -46,7 +50,14 @@
"Dialog.closeDialog": "Close dialog", "Dialog.closeDialog": "Close dialog",
"EditableDayPicker.today": "Today", "EditableDayPicker.today": "Today",
"EmptyCenterPanel.no-content": "Add or select a board from the sidebar to get started.", "EmptyCenterPanel.no-content": "Add or select a board from the sidebar to get started.",
"EmptyCenterPanel.workspace": "This is the workspace for:", "EmptyCenterPanel.plugin.choose-a-template": "Choose a template",
"EmptyCenterPanel.plugin.empty-board": "Start with an Empty Board",
"EmptyCenterPanel.plugin.end-message": "You can change the channel using the switcher in the sidebar.",
"EmptyCenterPanel.plugin.new-template": "New template",
"EmptyCenterPanel.plugin.no-content-description": "Add a board to the sidebar using any of the templates defined below or start from scratch.{lineBreak} Members of \"{workspaceName}\" will have access to boards created here.",
"EmptyCenterPanel.plugin.no-content-or": "or",
"EmptyCenterPanel.plugin.no-content-title": "Create a Board in {workspaceName}",
"Error.mobileweb": "Mobile web support is currently in early beta. Not all functionality may be present.",
"Error.websocket-closed": "Websocket connection closed, connection interrupted. If this persists, check your server or web proxy configuration.", "Error.websocket-closed": "Websocket connection closed, connection interrupted. If this persists, check your server or web proxy configuration.",
"Filter.includes": "includes", "Filter.includes": "includes",
"Filter.is-empty": "is empty", "Filter.is-empty": "is empty",
@ -54,17 +65,17 @@
"Filter.not-includes": "doesn't include", "Filter.not-includes": "doesn't include",
"FilterComponent.add-filter": "+ Add filter", "FilterComponent.add-filter": "+ Add filter",
"FilterComponent.delete": "Delete", "FilterComponent.delete": "Delete",
"GalleryCard.delete": "Delete",
"GalleryCard.duplicate": "Duplicate",
"GalleryCard.copiedLink": "Copied!", "GalleryCard.copiedLink": "Copied!",
"GalleryCard.copyLink": "Copy link", "GalleryCard.copyLink": "Copy link",
"GroupBy.ungroup": "Ungroup", "GalleryCard.delete": "Delete",
"GalleryCard.duplicate": "Duplicate",
"General.BoardCount": "{count, plural, one {# Board} other {# Boards}}", "General.BoardCount": "{count, plural, one {# Board} other {# Boards}}",
"GroupBy.ungroup": "Ungroup",
"KanbanCard.copiedLink": "Copied!",
"KanbanCard.copyLink": "Copy link",
"KanbanCard.delete": "Delete", "KanbanCard.delete": "Delete",
"KanbanCard.duplicate": "Duplicate", "KanbanCard.duplicate": "Duplicate",
"KanbanCard.untitled": "Untitled", "KanbanCard.untitled": "Untitled",
"KanbanCard.copiedLink": "Copied!",
"KanbanCard.copyLink": "Copy link",
"Mutator.duplicate-board": "duplicate board", "Mutator.duplicate-board": "duplicate board",
"Mutator.new-board-from-template": "new board from template", "Mutator.new-board-from-template": "new board from template",
"Mutator.new-card-from-template": "new card from template", "Mutator.new-card-from-template": "new card from template",
@ -88,6 +99,7 @@
"PropertyType.URL": "URL", "PropertyType.URL": "URL",
"PropertyType.UpdatedBy": "Last updated by", "PropertyType.UpdatedBy": "Last updated by",
"PropertyType.UpdatedTime": "Last updated time", "PropertyType.UpdatedTime": "Last updated time",
"PropertyValueElement.empty": "Empty",
"RegistrationLink.confirmRegenerateToken": "This will invalidate previously shared links. Continue?", "RegistrationLink.confirmRegenerateToken": "This will invalidate previously shared links. Continue?",
"RegistrationLink.copiedLink": "Copied!", "RegistrationLink.copiedLink": "Copied!",
"RegistrationLink.copyLink": "Copy link", "RegistrationLink.copyLink": "Copy link",
@ -146,6 +158,7 @@
"View.NewBoardTitle": "Board view", "View.NewBoardTitle": "Board view",
"View.NewGalleryTitle": "Gallery view", "View.NewGalleryTitle": "Gallery view",
"View.NewTableTitle": "Table view", "View.NewTableTitle": "Table view",
"View.NewTemplateTitle": "Untitled Template",
"View.Table": "Table", "View.Table": "Table",
"ViewHeader.add-template": "New template", "ViewHeader.add-template": "New template",
"ViewHeader.delete-template": "Delete", "ViewHeader.delete-template": "Delete",
@ -162,6 +175,7 @@
"ViewHeader.search": "Search", "ViewHeader.search": "Search",
"ViewHeader.search-text": "Search text", "ViewHeader.search-text": "Search text",
"ViewHeader.select-a-template": "Select a template", "ViewHeader.select-a-template": "Select a template",
"ViewHeader.set-default-template": "Set as default",
"ViewHeader.share-board": "Share board", "ViewHeader.share-board": "Share board",
"ViewHeader.sort": "Sort", "ViewHeader.sort": "Sort",
"ViewHeader.untitled": "Untitled", "ViewHeader.untitled": "Untitled",

View file

@ -12,7 +12,7 @@
"check": "eslint --ext .tsx,.ts . --quiet --cache && stylelint **/*.scss", "check": "eslint --ext .tsx,.ts . --quiet --cache && stylelint **/*.scss",
"fix": "eslint --ext .tsx,.ts . --quiet --fix --cache && stylelint --fix **/*.scss", "fix": "eslint --ext .tsx,.ts . --quiet --fix --cache && stylelint --fix **/*.scss",
"fix:scss": "prettier --write './src/**/*.scss'", "fix:scss": "prettier --write './src/**/*.scss'",
"i18n-extract": "formatjs extract src/*/*/*.ts? src/*/*.ts? src/*.ts? --out-file i18n/tmp.json; formatjs compile i18n/tmp.json --out-file i18n/en.json; rm i18n/tmp.json", "i18n-extract": "formatjs extract ../mattermost-plugin/webapp/src/*/*/*.ts? src/*/*/*.ts? src/*/*.ts? src/*.ts? --out-file i18n/tmp.json; formatjs compile i18n/tmp.json --out-file i18n/en.json; rm i18n/tmp.json",
"runserver-test": "cd cypress && cross-env FOCALBOARD_SINGLE_USER_TOKEN=TESTTOKEN ../../bin/focalboard-server -single-user", "runserver-test": "cd cypress && cross-env FOCALBOARD_SINGLE_USER_TOKEN=TESTTOKEN ../../bin/focalboard-server -single-user",
"cypress:ci": "start-server-and-test runserver-test http://localhost:8088 cypress:run", "cypress:ci": "start-server-and-test runserver-test http://localhost:8088 cypress:run",
"cypress:run": "cypress run", "cypress:run": "cypress run",

View file

@ -214,6 +214,15 @@ class OctoClient {
return this.getBlocksWithPath(path) return this.getBlocksWithPath(path)
} }
async getBlocksWithBlockID(blockID: string, workspaceID?: string, optionalReadToken?: string): Promise<Block[]> {
let path = this.workspacePath(workspaceID) + `/blocks?block_id=${blockID}`
const readToken = optionalReadToken || Utils.getReadToken()
if (readToken) {
path += `&read_token=${readToken}`
}
return this.getBlocksWithPath(path)
}
async getAllBlocks(): Promise<Block[]> { async getAllBlocks(): Promise<Block[]> {
const path = this.workspacePath() + '/blocks?all=true' const path = this.workspacePath() + '/blocks?all=true'
return this.getBlocksWithPath(path) return this.getBlocksWithPath(path)