1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-11-24 08:22:29 +02:00

Merge remote-tracking branch 'origin/main' into auth

This commit is contained in:
Jesús Espino 2020-12-04 16:04:28 +01:00
commit c8ac701587
42 changed files with 499 additions and 123 deletions

View File

@ -242,7 +242,7 @@ func (a *API) handleImport(w http.ResponseWriter, r *http.Request) {
err = a.app().InsertBlocks(blocks)
if err != nil {
log.Printf(`ERROR: %v`, r)
log.Printf(`ERROR: %v`, err)
errorResponse(w, http.StatusInternalServerError, nil)
return

View File

@ -9,6 +9,7 @@ import (
type Block struct {
ID string `json:"id"`
ParentID string `json:"parentId"`
RootID string `json:"rootId"`
Schema int64 `json:"schema"`
Type string `json:"type"`
Title string `json:"title"`

View File

@ -33,6 +33,20 @@ func (m *MockStore) EXPECT() *MockStoreMockRecorder {
return m.recorder
}
// CreateSession mocks base method
func (m *MockStore) CreateSession(arg0 *model.Session) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateSession", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// CreateSession indicates an expected call of CreateSession
func (mr *MockStoreMockRecorder) CreateSession(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSession", reflect.TypeOf((*MockStore)(nil).CreateSession), arg0)
}
// CreateUser mocks base method
func (m *MockStore) CreateUser(arg0 *model.User) error {
m.ctrl.T.Helper()
@ -61,6 +75,20 @@ func (mr *MockStoreMockRecorder) DeleteBlock(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBlock", reflect.TypeOf((*MockStore)(nil).DeleteBlock), arg0)
}
// DeleteSession mocks base method
func (m *MockStore) DeleteSession(arg0 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteSession", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteSession indicates an expected call of DeleteSession
func (mr *MockStoreMockRecorder) DeleteSession(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSession", reflect.TypeOf((*MockStore)(nil).DeleteSession), arg0)
}
// GetAllBlocks mocks base method
func (m *MockStore) GetAllBlocks() ([]model.Block, error) {
m.ctrl.T.Helper()
@ -136,6 +164,21 @@ func (mr *MockStoreMockRecorder) GetParentID(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetParentID", reflect.TypeOf((*MockStore)(nil).GetParentID), arg0)
}
// GetSession mocks base method
func (m *MockStore) GetSession(arg0 string) (*model.Session, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSession", arg0)
ret0, _ := ret[0].(*model.Session)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetSession indicates an expected call of GetSession
func (mr *MockStoreMockRecorder) GetSession(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSession", reflect.TypeOf((*MockStore)(nil).GetSession), arg0)
}
// GetSubTree2 mocks base method
func (m *MockStore) GetSubTree2(arg0 string) ([]model.Block, error) {
m.ctrl.T.Helper()
@ -240,6 +283,20 @@ func (mr *MockStoreMockRecorder) InsertBlock(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBlock", reflect.TypeOf((*MockStore)(nil).InsertBlock), arg0)
}
// RefreshSession mocks base method
func (m *MockStore) RefreshSession(arg0 *model.Session) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RefreshSession", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// RefreshSession indicates an expected call of RefreshSession
func (mr *MockStoreMockRecorder) RefreshSession(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RefreshSession", reflect.TypeOf((*MockStore)(nil).RefreshSession), arg0)
}
// SetSystemSetting mocks base method
func (m *MockStore) SetSystemSetting(arg0, arg1 string) error {
m.ctrl.T.Helper()
@ -268,6 +325,20 @@ func (mr *MockStoreMockRecorder) Shutdown() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Shutdown", reflect.TypeOf((*MockStore)(nil).Shutdown))
}
// UpdateSession mocks base method
func (m *MockStore) UpdateSession(arg0 *model.Session) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateSession", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateSession indicates an expected call of UpdateSession
func (mr *MockStoreMockRecorder) UpdateSession(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSession", reflect.TypeOf((*MockStore)(nil).UpdateSession), arg0)
}
// UpdateUser mocks base method
func (m *MockStore) UpdateUser(arg0 *model.User) error {
m.ctrl.T.Helper()

View File

@ -3,6 +3,7 @@ package sqlstore
import (
"database/sql"
"encoding/json"
"errors"
"log"
"time"
@ -23,7 +24,7 @@ func (s *SQLStore) latestsBlocksSubquery() sq.SelectBuilder {
func (s *SQLStore) GetBlocksWithParentAndType(parentID string, blockType string) ([]model.Block, error) {
query := s.getQueryBuilder().
Select("id", "parent_id", "schema", "type", "title",
Select("id", "parent_id", "root_id", "schema", "type", "title",
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest").
@ -42,7 +43,7 @@ func (s *SQLStore) GetBlocksWithParentAndType(parentID string, blockType string)
func (s *SQLStore) GetBlocksWithParent(parentID string) ([]model.Block, error) {
query := s.getQueryBuilder().
Select("id", "parent_id", "schema", "type", "title",
Select("id", "parent_id", "root_id", "schema", "type", "title",
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest").
@ -60,7 +61,7 @@ func (s *SQLStore) GetBlocksWithParent(parentID string) ([]model.Block, error) {
func (s *SQLStore) GetBlocksWithType(blockType string) ([]model.Block, error) {
query := s.getQueryBuilder().
Select("id", "parent_id", "schema", "type", "title",
Select("id", "parent_id", "root_id", "schema", "type", "title",
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest").
@ -79,7 +80,7 @@ func (s *SQLStore) GetBlocksWithType(blockType string) ([]model.Block, error) {
// GetSubTree2 returns blocks within 2 levels of the given blockID
func (s *SQLStore) GetSubTree2(blockID string) ([]model.Block, error) {
query := s.getQueryBuilder().
Select("id", "parent_id", "schema", "type", "title",
Select("id", "parent_id", "root_id", "schema", "type", "title",
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest").
@ -98,7 +99,7 @@ func (s *SQLStore) GetSubTree2(blockID string) ([]model.Block, error) {
// GetSubTree3 returns blocks within 3 levels of the given blockID
func (s *SQLStore) GetSubTree3(blockID string) ([]model.Block, error) {
// This first subquery returns repeated blocks
subquery1 := sq.Select("l3.id", "l3.parent_id", "l3.schema", "l3.type", "l3.title",
subquery1 := sq.Select("l3.id", "l3.parent_id", "l3.root_id", "l3.schema", "l3.type", "l3.title",
"l3.fields", "l3.create_at", "l3.update_at",
"l3.delete_at").
FromSelect(s.latestsBlocksSubquery(), "l1").
@ -111,7 +112,7 @@ func (s *SQLStore) GetSubTree3(blockID string) ([]model.Block, error) {
subquery2 := sq.Select("*", "ROW_NUMBER() OVER (PARTITION BY id) AS rn").
FromSelect(subquery1, "sub1")
query := s.getQueryBuilder().Select("id", "parent_id", "schema", "type", "title",
query := s.getQueryBuilder().Select("id", "parent_id", "root_id", "schema", "type", "title",
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at").
FromSelect(subquery2, "sub2").
@ -129,7 +130,7 @@ func (s *SQLStore) GetSubTree3(blockID string) ([]model.Block, error) {
func (s *SQLStore) GetAllBlocks() ([]model.Block, error) {
query := s.getQueryBuilder().
Select("id", "parent_id", "schema", "type", "title",
Select("id", "parent_id", "root_id", "schema", "type", "title",
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest")
@ -156,6 +157,7 @@ func blocksFromRows(rows *sql.Rows) ([]model.Block, error) {
err := rows.Scan(
&block.ID,
&block.ParentID,
&block.RootID,
&block.Schema,
&block.Type,
&block.Title,
@ -202,14 +204,18 @@ func (s *SQLStore) GetParentID(blockID string) (string, error) {
}
func (s *SQLStore) InsertBlock(block model.Block) error {
if block.RootID == "" {
return errors.New("rootId is nil")
}
fieldsJSON, err := json.Marshal(block.Fields)
if err != nil {
return err
}
query := s.getQueryBuilder().Insert("blocks").
Columns("id", "parent_id", "schema", "type", "title", "fields", "create_at", "update_at", "delete_at").
Values(block.ID, block.ParentID, block.Schema, block.Type, block.Title,
Columns("id", "parent_id", "root_id", "schema", "type", "title", "fields", "create_at", "update_at", "delete_at").
Values(block.ID, block.ParentID, block.RootID, block.Schema, block.Type, block.Title,
fieldsJSON, block.CreateAt, block.UpdateAt, block.DeleteAt)
_, err = query.Exec()

View File

@ -79,6 +79,24 @@ func _000003_auth_table_up_sql() ([]byte, error) {
)
}
var __000003_blocks_rootid_down_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\xf4\x09\x71\x0d\x52\x08\x71\x74\xf2\x71\x55\x48\xca\xc9\x4f\xce\x2e\xe6\x72\x09\xf2\x0f\x50\x70\xf6\xf7\x09\xf5\xf5\x53\x28\xca\xcf\x2f\x89\xcf\x4c\xb1\xe6\x02\x04\x00\x00\xff\xff\x94\x1c\x55\xb9\x28\x00\x00\x00")
func _000003_blocks_rootid_down_sql() ([]byte, error) {
return bindata_read(
__000003_blocks_rootid_down_sql,
"000003_blocks_rootid.down.sql",
)
}
var __000003_blocks_rootid_up_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\xf4\x09\x71\x0d\x52\x08\x71\x74\xf2\x71\x55\x48\xca\xc9\x4f\xce\x2e\xe6\x72\x74\x71\x51\x70\xf6\xf7\x09\xf5\xf5\x53\x28\xca\xcf\x2f\x89\xcf\x4c\x51\x08\x73\x0c\x72\xf6\x70\x0c\xd2\x30\x36\xd3\xb4\xe6\x02\x04\x00\x00\xff\xff\xce\x60\x70\x4e\x33\x00\x00\x00")
func _000003_blocks_rootid_up_sql() ([]byte, error) {
return bindata_read(
__000003_blocks_rootid_up_sql,
"000003_blocks_rootid.up.sql",
)
}
// Asset loads and returns the asset for the given name.
// It returns an error if the asset could not be found or
// could not be loaded.
@ -107,6 +125,8 @@ var _bindata = map[string]func() ([]byte, error){
"000002_system_settings_table.up.sql": _000002_system_settings_table_up_sql,
"000003_auth_table.down.sql": _000003_auth_table_down_sql,
"000003_auth_table.up.sql": _000003_auth_table_up_sql,
"000003_blocks_rootid.down.sql": _000003_blocks_rootid_down_sql,
"000003_blocks_rootid.up.sql": _000003_blocks_rootid_up_sql,
}
// AssetDir returns the file names below a certain
// directory embedded in the file by go-bindata.
@ -160,4 +180,8 @@ var _bintree = &_bintree_t{nil, map[string]*_bintree_t{
}},
"000003_auth_table.up.sql": &_bintree_t{_000003_auth_table_up_sql, map[string]*_bintree_t{
}},
"000003_blocks_rootid.down.sql": &_bintree_t{_000003_blocks_rootid_down_sql, map[string]*_bintree_t{
}},
"000003_blocks_rootid.up.sql": &_bintree_t{_000003_blocks_rootid_up_sql, map[string]*_bintree_t{
}},
}}

View File

@ -0,0 +1,2 @@
ALTER TABLE blocks
DROP COLUMN root_id;

View File

@ -0,0 +1,2 @@
ALTER TABLE blocks
ADD COLUMN root_id VARCHAR(36);

View File

@ -79,6 +79,24 @@ func _000003_auth_table_up_sql() ([]byte, error) {
)
}
var __000003_blocks_rootid_down_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\xf4\x09\x71\x0d\x52\x08\x71\x74\xf2\x71\x55\x48\xca\xc9\x4f\xce\x2e\xe6\x72\x09\xf2\x0f\x50\x70\xf6\xf7\x09\xf5\xf5\x53\x28\xca\xcf\x2f\x89\xcf\x4c\xb1\xe6\x02\x04\x00\x00\xff\xff\x94\x1c\x55\xb9\x28\x00\x00\x00")
func _000003_blocks_rootid_down_sql() ([]byte, error) {
return bindata_read(
__000003_blocks_rootid_down_sql,
"000003_blocks_rootid.down.sql",
)
}
var __000003_blocks_rootid_up_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\xf4\x09\x71\x0d\x52\x08\x71\x74\xf2\x71\x55\x48\xca\xc9\x4f\xce\x2e\xe6\x72\x74\x71\x51\x70\xf6\xf7\x09\xf5\xf5\x53\x28\xca\xcf\x2f\x89\xcf\x4c\x51\x08\x73\x0c\x72\xf6\x70\x0c\xd2\x30\x36\xd3\xb4\xe6\x02\x04\x00\x00\xff\xff\xce\x60\x70\x4e\x33\x00\x00\x00")
func _000003_blocks_rootid_up_sql() ([]byte, error) {
return bindata_read(
__000003_blocks_rootid_up_sql,
"000003_blocks_rootid.up.sql",
)
}
// Asset loads and returns the asset for the given name.
// It returns an error if the asset could not be found or
// could not be loaded.
@ -107,6 +125,8 @@ var _bindata = map[string]func() ([]byte, error){
"000002_system_settings_table.up.sql": _000002_system_settings_table_up_sql,
"000003_auth_table.down.sql": _000003_auth_table_down_sql,
"000003_auth_table.up.sql": _000003_auth_table_up_sql,
"000003_blocks_rootid.down.sql": _000003_blocks_rootid_down_sql,
"000003_blocks_rootid.up.sql": _000003_blocks_rootid_up_sql,
}
// AssetDir returns the file names below a certain
// directory embedded in the file by go-bindata.
@ -160,4 +180,8 @@ var _bintree = &_bintree_t{nil, map[string]*_bintree_t{
}},
"000003_auth_table.up.sql": &_bintree_t{_000003_auth_table_up_sql, map[string]*_bintree_t{
}},
"000003_blocks_rootid.down.sql": &_bintree_t{_000003_blocks_rootid_down_sql, map[string]*_bintree_t{
}},
"000003_blocks_rootid.up.sql": &_bintree_t{_000003_blocks_rootid_up_sql, map[string]*_bintree_t{
}},
}}

View File

@ -0,0 +1,2 @@
ALTER TABLE blocks
DROP COLUMN root_id;

View File

@ -0,0 +1,2 @@
ALTER TABLE blocks
ADD COLUMN root_id VARCHAR(36);

View File

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {BoardTree} from './viewModel/boardTree'
import mutator from './mutator'
import {IBlock} from './blocks/block'
import {IBlock, IMutableBlock} from './blocks/block'
import {Utils} from './utils'
interface Archive {
@ -67,14 +67,15 @@ class Archiver {
Utils.log(`Import archive, version: ${archive.version}, date/time: ${date.toLocaleString()}, ${blocks.length} block(s).`)
// Basic error checking
const filteredBlocks = blocks.filter((o) => {
if (!o.id) {
return false
}
return true
})
let filteredBlocks = blocks.filter((o) => Boolean(o.id))
Utils.log(`Import ${filteredBlocks.length} filtered blocks.`)
Utils.log(`Import ${filteredBlocks.length} filtered blocks with ids.`)
this.fixRootIds(filteredBlocks)
filteredBlocks = filteredBlocks.filter((o) => Boolean(o.rootId))
Utils.log(`Import ${filteredBlocks.length} filtered blocks with rootIds.`)
await mutator.importFullArchive(filteredBlocks)
Utils.log('Import completed')
@ -87,6 +88,42 @@ class Archiver {
// TODO: Remove or reuse input
}
private static fixRootIds(blocks: IMutableBlock[]) {
const blockMap = new Map(blocks.map((o) => [o.id, o]))
const maxLevels = 5
for (let i = 0; i < maxLevels; i++) {
let missingRootIds = false
blocks.forEach((o) => {
if (o.parentId) {
const parent = blockMap.get(o.parentId)
if (parent) {
o.rootId = parent.rootId
} else {
Utils.assert(`No parent for ${o.type}: ${o.id} (${o.title})`)
}
if (!o.rootId) {
missingRootIds = true
}
} else {
o.rootId = o.id
}
})
if (!missingRootIds) {
Utils.log(`fixRootIds in ${i} levels`)
break
}
}
// Check and log remaining errors
blocks.forEach((o) => {
if (!o.rootId) {
const parent = blockMap.get(o.parentId)
Utils.logError(`RootId is null: ${o.type} ${o.id}, parentId ${o.parentId}: ${o.title}, parent: ${parent?.type}, parent.rootId: ${parent?.rootId}, parent.title: ${parent?.title}`)
}
})
}
}
export {Archiver}

View File

@ -7,6 +7,7 @@ type BlockTypes = 'board' | 'view' | 'card' | 'text' | 'image' | 'divider' | 'co
interface IBlock {
readonly id: string
readonly parentId: string
readonly rootId: string
readonly schema: number
readonly type: BlockTypes
@ -21,6 +22,7 @@ interface IBlock {
interface IMutableBlock extends IBlock {
id: string
parentId: string
rootId: string
schema: number
type: BlockTypes
@ -36,6 +38,7 @@ class MutableBlock implements IMutableBlock {
id: string = Utils.createGuid()
schema: number
parentId: string
rootId: string
type: BlockTypes
title: string
fields: Record<string, any> = {}
@ -59,6 +62,7 @@ class MutableBlock implements IMutableBlock {
this.id = block.id || Utils.createGuid()
this.schema = 1
this.parentId = block.parentId || ''
this.rootId = block.rootId || ''
this.type = block.type || ''
// Shallow copy here. Derived classes must make deep copies of their known properties in their constructors.

View File

@ -102,9 +102,9 @@ class MutableBoard extends MutableBlock {
}
duplicate(): MutableBoard {
const card = new MutableBoard(this)
card.id = Utils.createGuid()
return card
const board = new MutableBoard(this)
board.id = Utils.createGuid()
return board
}
}

View File

@ -497,6 +497,7 @@ class BoardComponent extends React.Component<Props, State> {
const card = new MutableCard()
card.parentId = boardTree.board.id
card.rootId = boardTree.board.rootId
const propertiesThatMeetFilters = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
if (boardTree.groupByProperty) {
if (groupByOptionId) {
@ -527,6 +528,7 @@ class BoardComponent extends React.Component<Props, State> {
const cardTemplate = new MutableCard()
cardTemplate.isTemplate = true
cardTemplate.parentId = boardTree.board.id
cardTemplate.rootId = boardTree.board.rootId
await mutator.insertBlock(
cardTemplate,
'add card template',

View File

@ -72,7 +72,7 @@ class CardDetail extends React.Component<Props, State> {
<ContentBlock
key={block.id}
block={block}
cardId={card.id}
card={card}
contents={cardTree.contents}
/>
))}
@ -88,6 +88,7 @@ class CardDetail extends React.Component<Props, State> {
if (text) {
const block = new MutableTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.title = text
block.order = (this.props.cardTree.contents.length + 1) * 1000
mutator.insertBlock(block, 'add card text')
@ -215,6 +216,7 @@ class CardDetail extends React.Component<Props, State> {
onClick={() => {
const block = new MutableTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.order = (this.props.cardTree.contents.length + 1) * 1000
mutator.insertBlock(block, 'add text')
}}
@ -223,7 +225,7 @@ class CardDetail extends React.Component<Props, State> {
id='image'
name={intl.formatMessage({id: 'CardDetail.image', defaultMessage: 'Image'})}
onClick={() => Utils.selectLocalFile(
(file) => mutator.createImageBlock(card.id, file, (this.props.cardTree.contents.length + 1) * 1000),
(file) => mutator.createImageBlock(card, file, (this.props.cardTree.contents.length + 1) * 1000),
'.jpg,.jpeg,.png',
)}
/>

View File

@ -46,6 +46,9 @@
}
.comment-text * {
user-select: text;
-webkit-user-select: text; /* Chrome all / Safari all */
-moz-user-select: text; /* Firefox all */
-ms-user-select: text; /* IE 10+ */
user-select: text; /* Likely future */
}
}

View File

@ -3,6 +3,7 @@
import React from 'react'
import {IBlock} from '../blocks/block'
import {MutableDividerBlock} from '../blocks/dividerBlock'
import {IOrderedBlock} from '../blocks/orderedBlock'
import {MutableTextBlock} from '../blocks/textBlock'
@ -26,13 +27,13 @@ import {MarkdownEditor} from './markdownEditor'
type Props = {
block: IOrderedBlock
cardId: string
card: IBlock
contents: readonly IOrderedBlock[]
}
class ContentBlock extends React.PureComponent<Props> {
public render(): JSX.Element | null {
const {cardId, contents, block} = this.props
const {card, contents, block} = this.props
if (block.type !== 'text' && block.type !== 'image' && block.type !== 'divider') {
Utils.assertFailure(`Block type is unknown: ${block.type}`)
@ -81,7 +82,8 @@ class ContentBlock extends React.PureComponent<Props> {
icon={<TextIcon/>}
onClick={() => {
const newBlock = new MutableTextBlock()
newBlock.parentId = cardId
newBlock.parentId = card.id
newBlock.rootId = card.rootId
// TODO: Handle need to reorder all blocks
newBlock.order = OctoUtils.getOrderBefore(block, contents)
@ -96,7 +98,7 @@ class ContentBlock extends React.PureComponent<Props> {
onClick={() => {
Utils.selectLocalFile(
(file) => {
mutator.createImageBlock(cardId, file, OctoUtils.getOrderBefore(block, contents))
mutator.createImageBlock(card, file, OctoUtils.getOrderBefore(block, contents))
},
'.jpg,.jpeg,.png')
}}
@ -107,7 +109,8 @@ class ContentBlock extends React.PureComponent<Props> {
icon={<DividerIcon/>}
onClick={() => {
const newBlock = new MutableDividerBlock()
newBlock.parentId = cardId
newBlock.parentId = card.id
newBlock.rootId = card.rootId
// TODO: Handle need to reorder all blocks
newBlock.order = OctoUtils.getOrderBefore(block, contents)

View File

@ -26,6 +26,11 @@
margin: 72px auto;
max-width: 975px;
height: calc(100% - 144px);
.hideOnWidescreen {
/* Hide controls (e.g. close button) on larger screens */
display: none !important;
}
}
@media screen and (max-width: 975px) {
position: fixed;
@ -48,13 +53,6 @@
flex-direction: row;
height: 30px;
margin: 10px;
> .IconButton:first-child {
/* Hide close button on larger screens */
@media not screen and (max-width: 975px) {
display: none;
}
}
}
> .content {
display: flex;

View File

@ -53,6 +53,7 @@ export default class Dialog extends React.PureComponent<Props> {
onClick={this.closeClicked}
icon={<CloseIcon/>}
title={'Close dialog'}
className='hideOnWidescreen'
/>
<div className='octo-spacer'/>
<MenuWrapper>

View File

@ -4,11 +4,32 @@
left: -200px;
z-index: 10;
min-width: 420px;
min-width: 430px;
box-shadow: rgba(var(--main-fg), 0.1) 0px 0px 0px 1px, rgba(var(--main-fg), 0.1) 0px 2px 4px;
background-color: rgb(var(--main-bg));
padding: 10px;
@media screen and (max-width: 430px) {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-width: 0;
}
.hideOnWidescreen {
/* Hide controls (e.g. close button) on larger screens */
@media not screen and (max-width: 430px) {
display: none !important;
}
}
> .toolbar {
display: flex;
flex-direction: row;
height: 30px;
margin-bottom: 10px;
}
.octo-filterclause {
display: flex;
flex-direction: row;

View File

@ -1,20 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage, IntlShape, injectIntl} from 'react-intl'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {IPropertyTemplate} from '../blocks/board'
import {BoardTree} from '../viewModel/boardTree'
import {FilterClause, FilterCondition} from '../filterClause'
import {FilterGroup} from '../filterGroup'
import mutator from '../mutator'
import {Utils} from '../utils'
import MenuWrapper from '../widgets/menuWrapper'
import Menu from '../widgets/menu'
import {BoardTree} from '../viewModel/boardTree'
import Button from '../widgets/buttons/button'
import IconButton from '../widgets/buttons/iconButton'
import CloseIcon from '../widgets/icons/close'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import './filterComponent.scss'
type Props = {
@ -80,11 +79,18 @@ class FilterComponent extends React.Component<Props> {
className='FilterComponent'
ref={this.node}
>
<div className='toolbar hideOnWidescreen'>
<IconButton
onClick={this.closeClicked}
icon={<CloseIcon/>}
title={'Close dialog'}
/>
</div>
{filters.map((filter) => {
const template = board.cardProperties.find((o) => o.id === filter.propertyId)
const propertyName = template ? template.name : '(unknown)' // TODO: Handle error
const key = `${filter.propertyId}-${filter.condition}-${filter.values.join(',')}`
Utils.log(`FilterClause key: ${key}`)
return (
<div
className='octo-filterclause'
@ -213,6 +219,10 @@ class FilterComponent extends React.Component<Props> {
return undefined
}
private closeClicked = () => {
this.props.onClose()
}
private deleteClicked(filter: FilterClause) {
const {boardTree} = this.props
const {activeView: view} = boardTree

View File

@ -334,10 +334,12 @@ class Sidebar extends React.Component<Props, State> {
const oldBoardId = this.props.activeBoardId
const board = new MutableBoard()
board.rootId = board.id
const view = new MutableBoardView()
view.viewType = 'board'
view.parentId = board.id
view.rootId = board.rootId
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board View'})
await mutator.insertBlocks(
@ -412,6 +414,7 @@ class Sidebar extends React.Component<Props, State> {
const {activeBoardId} = this.props
const boardTemplate = new MutableBoard()
boardTemplate.rootId = boardTemplate.id
boardTemplate.isTemplate = true
await mutator.insertBlock(
boardTemplate,

View File

@ -323,6 +323,7 @@ class TableComponent extends React.Component<Props, State> {
const card = new MutableCard()
card.parentId = boardTree.board.id
card.rootId = boardTree.board.rootId
if (!card.icon) {
card.icon = BlockIcons.shared.randomIcon()
}
@ -346,6 +347,7 @@ class TableComponent extends React.Component<Props, State> {
const cardTemplate = new MutableCard()
cardTemplate.isTemplate = true
cardTemplate.parentId = boardTree.board.id
cardTemplate.rootId = boardTree.board.rootId
await mutator.insertBlock(
cardTemplate,
'add card template',

View File

@ -65,11 +65,11 @@ class ViewHeader extends React.Component<Props, State> {
}
}
private filterClicked = () => {
private showFilterDialog = () => {
this.setState({showFilter: true})
}
private hideFilter = () => {
private hideFilterDialog = () => {
this.setState({showFilter: false})
}
@ -99,6 +99,7 @@ class ViewHeader extends React.Component<Props, State> {
for (let i = 0; i < count; i++) {
const card = new MutableCard()
card.parentId = boardTree.board.id
card.rootId = boardTree.board.rootId
card.properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
card.title = `Test Card ${startCount + i + 1}`
card.icon = BlockIcons.shared.randomIcon()
@ -235,18 +236,18 @@ class ViewHeader extends React.Component<Props, State> {
<div className='filter-container'>
<Button
active={hasFilter}
onClick={this.filterClicked}
onClick={this.showFilterDialog}
>
<FormattedMessage
id='ViewHeader.filter'
defaultMessage='Filter'
/>
{this.state.showFilter &&
<FilterComponent
boardTree={boardTree}
onClose={this.hideFilter}
/>}
</Button>
{this.state.showFilter &&
<FilterComponent
boardTree={boardTree}
onClose={this.hideFilterDialog}
/>}
</div>
<MenuWrapper>
<Button active={hasSort}>

View File

@ -47,6 +47,7 @@ export class ViewMenu extends React.PureComponent<Props> {
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board View'})
view.viewType = 'board'
view.parentId = board.id
view.rootId = board.rootId
const oldViewId = boardTree.activeView.id
@ -69,6 +70,7 @@ export class ViewMenu extends React.PureComponent<Props> {
view.title = intl.formatMessage({id: 'View.NewTableTitle', defaultMessage: 'Table View'})
view.viewType = 'table'
view.parentId = board.id
view.rootId = board.rootId
view.visiblePropertyIds = board.cardProperties.map((o) => o.id)
view.columnWidths = {}
view.columnWidths[Constants.titleColumnId] = Constants.defaultTitleColumnWidth

View File

@ -2,7 +2,8 @@
flex: 0 0 auto;
display: flex;
flex-direction: row;
align-items: center;
align-items: center;
min-width: 300px;
&.add-buttons {
flex-direction: row;

View File

@ -573,18 +573,19 @@ class Mutator {
}
// Not a mutator, but convenient to put here since Mutator wraps OctoClient
async importFullArchive(blocks: IBlock[]): Promise<Response> {
async importFullArchive(blocks: readonly IBlock[]): Promise<Response> {
return octoClient.importFullArchive(blocks)
}
async createImageBlock(parentId: string, file: File, order = 1000): Promise<IBlock | undefined> {
async createImageBlock(parent: IBlock, file: File, order = 1000): Promise<IBlock | undefined> {
const url = await octoClient.uploadFile(file)
if (!url) {
return undefined
}
const block = new MutableImageBlock()
block.parentId = parentId
block.parentId = parent.id
block.rootId = parent.rootId
block.order = order
block.url = url

View File

@ -74,7 +74,7 @@ class OctoClient {
return blocks
}
async importFullArchive(blocks: IBlock[]): Promise<Response> {
async importFullArchive(blocks: readonly IBlock[]): Promise<Response> {
Utils.log(`importFullArchive: ${blocks.length} blocks(s)`)
blocks.forEach((block) => {
Utils.log(`\t ${block.type}, ${block.id}`)

View File

@ -1,5 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IBlock, MutableBlock} from './blocks/block'
import {IPropertyTemplate, MutableBoard} from './blocks/board'
import {MutableBoardView} from './blocks/boardView'
@ -88,7 +89,7 @@ class OctoUtils {
}
// Creates a copy of the blocks with new ids and parentIDs
static duplicateBlockTree(blocks: IBlock[], rootBlockId: string): [MutableBlock[], MutableBlock, Readonly<Record<string, string>>] {
static duplicateBlockTree(blocks: IBlock[], sourceBlockId: string): [MutableBlock[], MutableBlock, Readonly<Record<string, string>>] {
const idMap: Record<string, string> = {}
const newBlocks = blocks.map((block) => {
const newBlock = this.hydrateBlock(block)
@ -97,14 +98,29 @@ class OctoUtils {
return newBlock
})
const newRootBlockId = idMap[rootBlockId]
const newSourceBlockId = idMap[sourceBlockId]
// Determine the new rootId if needed
let newRootId: string
const sourceBlock = blocks.find((block) => block.id === sourceBlockId)!
if (sourceBlock.rootId === sourceBlock.id) {
// Special case: when duplicating a tree from root, remap all the descendant rootIds
const newSourceBlock = newBlocks.find((block) => block.id === newSourceBlockId)!
newRootId = newSourceBlock.id
}
newBlocks.forEach((newBlock) => {
// Note: Don't remap the parent of the new root block
if (newBlock.id !== newRootBlockId && newBlock.parentId) {
if (newBlock.id !== newSourceBlockId && newBlock.parentId) {
newBlock.parentId = idMap[newBlock.parentId] || newBlock.parentId
Utils.assert(newBlock.parentId, `Block ${newBlock.id} (${newBlock.type} ${newBlock.title}) has no parent`)
}
// Remap the rootIds if we are duplicating a tree from root
if (newRootId) {
newBlock.rootId = newRootId
}
// Remap manual card order
if (newBlock.type === 'view') {
const view = newBlock as MutableBoardView
@ -112,8 +128,8 @@ class OctoUtils {
}
})
const newRootBlock = newBlocks.find((block) => block.id === newRootBlockId)!
return [newBlocks, newRootBlock, idMap]
const newSourceBlock = newBlocks.find((block) => block.id === newSourceBlockId)!
return [newBlocks, newSourceBlock, idMap]
}
}

View File

@ -9,8 +9,11 @@
* {
box-sizing: border-box;
outline: 0;
user-select: none;
outline: 0;
-webkit-user-select: none; /* Chrome all / Safari all */
-moz-user-select: none; /* Firefox all */
-ms-user-select: none; /* IE 10+ */
user-select: none; /* Likely future */
}
html, body {

View File

@ -84,14 +84,13 @@ class MutableBoardTree implements BoardTree {
}
private ensureMinimumSchema(): boolean {
const {board} = this
let didChange = false
// At least one select property
const selectProperties = board?.cardProperties.find((o) => o.type === 'select')
const selectProperties = this.board?.cardProperties.find((o) => o.type === 'select')
if (!selectProperties) {
const newBoard = new MutableBoard(board)
const newBoard = new MutableBoard(this.board)
newBoard.rootId = newBoard.id
const property: IPropertyTemplate = {
id: Utils.createGuid(),
name: 'Status',
@ -106,8 +105,9 @@ class MutableBoardTree implements BoardTree {
// At least one view
if (this.views.length < 1) {
const view = new MutableBoardView()
view.parentId = board?.id
view.groupById = board?.cardProperties.find((o) => o.type === 'select')?.id
view.parentId = this.board.id
view.rootId = this.board.rootId
view.groupById = this.board.cardProperties.find((o) => o.type === 'select')?.id
this.views.push(view)
didChange = true
}

View File

@ -1,5 +1,6 @@
.Button {
display: flex;
flex: 0 0 auto;
align-items: center;
text-align: center;
justify-content: center;

View File

@ -13,8 +13,8 @@ type Props = {
active?: boolean
}
export default class Button extends React.Component<Props> {
render() {
export default class Button extends React.PureComponent<Props> {
render(): JSX.Element {
return (
<div
onClick={this.props.onClick}

View File

@ -8,14 +8,19 @@ type Props = {
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
title?: string
icon?: React.ReactNode
className?: string
}
export default class IconButton extends React.PureComponent<Props> {
render(): JSX.Element {
let className = 'Button IconButton'
if (this.props.className) {
className += ' ' + this.props.className
}
return (
<div
onClick={this.props.onClick}
className='Button IconButton'
className={className}
title={this.props.title}
>
{this.props.icon}

View File

@ -23,8 +23,8 @@ export default class ColorOption extends React.PureComponent<ColorOptionProps> {
className='MenuOption ColorOption menu-option'
onClick={this.handleOnClick}
>
{icon ?? <div className='noicon'/>}
<div className='menu-name'>{name}</div>
{icon}
<div className={`menu-colorbox ${id}`}/>
</div>
)

View File

@ -13,8 +13,9 @@ export default class LabelOption extends React.PureComponent<LabelOptionProps> {
public render(): JSX.Element {
return (
<div className='MenuOption LabelOption menu-option'>
{this.props.icon ?? <div className='noicon'/>}
<div className='menu-name'>{this.props.children}</div>
{this.props.icon}
<div className='noicon'/>
</div>
)
}

View File

@ -1,4 +1,6 @@
.Menu {
display: flex;
flex-direction: column;
position: absolute;
z-index: 15;
min-width: 180px;
@ -8,11 +10,9 @@
border-radius: 3px;
box-shadow: rgba(var(--main-fg), 0.05) 0px 0px 0px 1px, rgba(var(--main-fg), 0.1) 0px 3px 6px, rgba(var(--main-fg), 0.2) 0px 9px 24px;
&.top {
bottom: 100%;
}
&.left {
right: 0;
.menu-contents {
display: flex;
flex-direction: column;
}
.menu-options {
@ -20,47 +20,118 @@
flex-direction: column;
flex-grow: 1;
position: relative;
list-style: none;
padding: 0;
margin: 0;
color: rgb(var(--main-fg));
>.menu-option {
display: flex;
flex-direction: row;
align-items: center;
font-weight: 400;
padding: 2px 10px;
cursor: pointer;
&:hover {
background: rgba(90, 90, 90, 0.1);
}
>* {
margin-left: 5px;
}
>*:first-child {
margin-left: 0;
}
>.menu-name {
display: flex;
flex-grow: 1;
white-space: nowrap;
}
>.SubmenuTriangleIcon {
fill: rgba(var(--main-fg), 0.7);
}
>.Icon {
width: 16px;
height: 16px;
}
>.IconButton .Icon {
margin-right: 0;
}
}
}
.menu-option {
display: flex;
flex-direction: row;
align-items: center;
.menu-spacer {
height: 20px;
width: 20px;
flex: 0 0 auto;
}
}
font-weight: 400;
padding: 2px 10px;
cursor: pointer;
touch-action: none;
.Menu, .SubMenuOption .SubMenu {
@media screen and (max-width: 430px) {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-width: 0;
background-color: rgba(var(--main-fg), 0.5);
border-radius: 0;
padding: 10px;
&:hover {
background: rgba(90, 90, 90, 0.1);
display: block;
overflow-y: auto;
.menu-contents {
justify-content: flex-end;
min-height: 100%;
}
.menu-name {
display: flex;
flex-grow: 1;
white-space: nowrap;
margin-right: 20px;
.menu-options {
align-items: center;
border-radius: 10px;
overflow: hidden;
flex: 0 0 auto;
>.menu-option {
min-height: 44px;
width: 100%;
padding: 0 20px;
background-color: rgb(var(--main-bg));
>* {
flex: 0 0 auto;
}
>.noicon {
width: 16px;
height: 16px;
}
>.menu-name {
font-size: 16px;
justify-content: center;
}
}
}
}
@media not screen and (max-width: 430px) {
&.top {
bottom: 100%;
}
&.left {
right: 0;
}
.SubmenuTriangleIcon {
fill: rgba(var(--main-fg), 0.7);
}
.Icon {
width: 16px;
height: 16px;
margin-right: 5px;
}
.IconButton .Icon {
margin-right: 0;
.hideOnWidescreen {
/* Hide controls (e.g. close button) on larger screens */
display: none !important;
}
}
}

View File

@ -11,12 +11,12 @@ import LabelOption from './labelOption'
import './menu.scss'
type MenuProps = {
type Props = {
children: React.ReactNode
position?: 'top'|'bottom'|'left'
}
export default class Menu extends React.PureComponent<MenuProps> {
export default class Menu extends React.PureComponent<Props> {
static Color = ColorOption
static SubMenu = SubMenuOption
static Switch = SwitchOption
@ -28,10 +28,27 @@ export default class Menu extends React.PureComponent<MenuProps> {
const {position, children} = this.props
return (
<div className={'Menu noselect ' + (position || 'bottom')}>
<div className='menu-options'>
{children}
<div className='menu-contents'>
<div className='menu-options'>
{children}
</div>
<div className='menu-spacer hideOnWidescreen'/>
<div className='menu-options hideOnWidescreen'>
<Menu.Text
id='menu-cancel'
name={'Cancel'}
className='menu-cancel'
onClick={this.onCancel}
/>
</div>
</div>
</div>
)
}
private onCancel = () => {
// No need to do anything, as click bubbled up to MenuWrapper, which closes
}
}

View File

@ -3,10 +3,11 @@
.SubMenu {
position: absolute;
z-index: 15;
z-index: 16;
min-width: 180px;
background-color: rgb(var(--main-bg));
color: rgb(var(--main-fg));
margin: 0 !important;
border-radius: 3px;
box-shadow: rgba(var(--main-fg), 0.05) 0px 0px 0px 1px, rgba(var(--main-fg), 0.1) 0px 3px 6px, rgba(var(--main-fg), 0.2) 0px 9px 24px;
@ -19,7 +20,9 @@
}
}
.SubmenuTriangleIcon {
float: 'right'
@media screen and (max-width: 430px) {
.SubMenu {
background-color: rgba(var(--main-fg), 0.8) !important;
}
}
}

View File

@ -4,6 +4,8 @@ import React from 'react'
import SubmenuTriangleIcon from '../icons/submenuTriangle'
import Menu from '.'
import './subMenuOption.scss'
type SubMenuOptionProps = {
@ -23,6 +25,15 @@ export default class SubMenuOption extends React.PureComponent<SubMenuOptionProp
}
private handleMouseEnter = (): void => {
setTimeout(() => {
this.setState({isOpen: true})
}, 50)
}
// The click handler is needed to support Android Chrome
private handleClick = (e: React.MouseEvent): void => {
e.preventDefault()
e.stopPropagation()
this.setState({isOpen: true})
}
@ -36,18 +47,36 @@ export default class SubMenuOption extends React.PureComponent<SubMenuOptionProp
className='MenuOption SubMenuOption menu-option'
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.close}
onClick={this.handleClick}
>
{this.props.icon}
{this.props.icon ?? <div className='noicon'/>}
<div className='menu-name'>{this.props.name}</div>
<SubmenuTriangleIcon/>
{this.state.isOpen &&
<div className={'SubMenu menu noselect ' + (this.props.position || 'bottom')}>
<div className='menu-options'>
{this.props.children}
<div className={'SubMenu Menu noselect ' + (this.props.position || 'bottom')}>
<div className='menu-contents'>
<div className='menu-options'>
{this.props.children}
</div>
<div className='menu-spacer hideOnWidescreen'/>
<div className='menu-options hideOnWidescreen'>
<Menu.Text
id='menu-cancel'
name={'Cancel'}
className='menu-cancel'
onClick={this.onCancel}
/>
</div>
</div>
</div>
}
</div>
)
}
private onCancel = () => {
// No need to do anything, as click bubbled up to MenuWrapper, which closes
}
}

View File

@ -24,8 +24,8 @@ export default class SwitchOption extends React.PureComponent<SwitchOptionProps>
className='MenuOption SwitchOption menu-option'
onClick={this.handleOnClick}
>
{icon ?? <div className='noicon'/>}
<div className='menu-name'>{name}</div>
{icon}
<Switch
isOn={isOn}
onChanged={() => {}}

View File

@ -7,6 +7,7 @@ import {MenuOptionProps} from './menuItem'
type TextOptionProps = MenuOptionProps & {
icon?: React.ReactNode,
rightIcon?: React.ReactNode,
className?: string
}
export default class TextOption extends React.PureComponent<TextOptionProps> {
@ -17,14 +18,18 @@ export default class TextOption extends React.PureComponent<TextOptionProps> {
public render(): JSX.Element {
const {name, icon, rightIcon} = this.props
let className = 'MenuOption TextOption menu-option'
if (this.props.className) {
className += ' ' + this.props.className
}
return (
<div
className='MenuOption TextOption menu-option'
className={className}
onClick={this.handleOnClick}
>
{icon}
{icon ?? <div className='noicon'/>}
<div className='menu-name'>{name}</div>
{rightIcon}
{rightIcon ?? <div className='noicon'/>}
</div>
)
}