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:
commit
c8ac701587
@ -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
|
||||
|
@ -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"`
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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{
|
||||
}},
|
||||
}}
|
||||
|
@ -0,0 +1,2 @@
|
||||
ALTER TABLE blocks
|
||||
DROP COLUMN root_id;
|
@ -0,0 +1,2 @@
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN root_id VARCHAR(36);
|
@ -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{
|
||||
}},
|
||||
}}
|
||||
|
@ -0,0 +1,2 @@
|
||||
ALTER TABLE blocks
|
||||
DROP COLUMN root_id;
|
@ -0,0 +1,2 @@
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN root_id VARCHAR(36);
|
@ -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}
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
)}
|
||||
/>
|
||||
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
|
@ -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}>
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
||||
|
@ -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}`)
|
||||
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
.Button {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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={() => {}}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user