1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-07-03 23:30:29 +02:00

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

This commit is contained in:
Jesús Espino
2020-11-17 15:44:04 +01:00
83 changed files with 2521 additions and 1075 deletions

29
.vscode/launch.json vendored
View File

@ -20,6 +20,31 @@
"<node_internals>/**"
],
"type": "pwa-node"
}
]
},
{
"type": "node",
"request": "launch",
"name": "Jest: run all tests",
"program": "${workspaceRoot}/webapp/node_modules/jest/bin/jest.js",
"cwd": "${workspaceRoot}/webapp",
"args": [
"--verbose",
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "Jest: run current file",
"program": "${workspaceRoot}/webapp/node_modules/jest/bin/jest.js",
"cwd": "${workspaceRoot}/webapp",
"args": [
"${fileBasename}",
"--verbose",
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
]
}

View File

@ -1,4 +1,4 @@
.PHONY: prebuild clean cleanall server server-linux server-win64 generate watch-server webapp mac-app win-app linux-app
.PHONY: prebuild clean cleanall server server-mac server-linux server-win generate watch-server webapp mac-app win-app linux-app
all: server
@ -13,10 +13,15 @@ prebuild:
server:
cd server; go build -o ../bin/octoserver ./main
server-linux:
cd server; env GOOS=linux GOARCH=amd64 go build -o ../bin/octoserver ./main
server-mac:
mkdir -p bin/mac
cd server; env GOOS=darwin GOARCH=amd64 go build -o ../bin/mac/octoserver ./main
server-win64:
server-linux:
mkdir -p bin/linux
cd server; env GOOS=linux GOARCH=amd64 go build -o ../bin/linux/octoserver ./main
server-win:
cd server; env GOOS=windows GOARCH=amd64 go build -o ../bin/octoserver.exe ./main
generate:
@ -43,18 +48,18 @@ watch-server:
webapp:
cd webapp; npm run pack
mac-app: server webapp
mac-app: server-mac webapp
rm -rf mac/resources/bin
rm -rf mac/resources/pack
mkdir -p mac/resources
cp -R bin mac/resources/bin
mkdir -p mac/resources/bin
cp bin/mac/octoserver mac/resources/bin/octoserver
cp -R webapp/pack mac/resources/pack
mkdir -p mac/temp
xcodebuild archive -workspace mac/Tasks.xcworkspace -scheme Tasks -archivePath mac/temp/tasks.xcarchive
xcodebuild -exportArchive -archivePath mac/temp/tasks.xcarchive -exportPath mac/dist -exportOptionsPlist mac/export.plist
cd mac/dist; zip -r tasks.zip Tasks.app
cd mac/dist; zip -r tasks-mac.zip Tasks.app
win-app: server-win64 webapp
win-app: server-win webapp
cd win; make build
mkdir -p win/dist/bin
cp -R bin/octoserver.exe win/dist/bin
@ -67,7 +72,7 @@ linux-app: server-linux webapp
rm -rf linux/temp
mkdir -p linux/temp/tasks-app/webapp
mkdir -p linux/dist
cp -R bin/octoserver linux/temp/tasks-app/
cp -R bin/linux/octoserver linux/temp/tasks-app/
cp -R config.json linux/temp/tasks-app/
cp -R webapp/pack linux/temp/tasks-app/webapp/pack
cd linux; make build
@ -81,6 +86,8 @@ clean:
rm -rf webapp/pack
rm -rf mac/temp
rm -rf mac/dist
rm -rf linux/dist
rm -rf win/dist
cleanall: clean
rm -rf webapp/node_modules

View File

@ -3,10 +3,6 @@
## Building the server
```
cd webapp
npm install
npm run packdev
cd ..
make prebuild
make
```
@ -15,8 +11,9 @@ Currently tested with:
* Go 1.15.2
* MacOS Catalina (10.15.6)
* Ubuntu 18.04
* Windows 10
The server defaults to using sqlite as the store, but can be configured to use Postgres:
The server defaults to using SQLite as the store, but can be configured to use Postgres:
* In config.json
* Set dbtype to "postgres"
* Set dbconfig to the connection string (which you can copy from dbconfig_postgres)
@ -25,21 +22,26 @@ The server defaults to using sqlite as the store, but can be configured to use P
## Running and testing the server
To start the server:
```
./bin/octoserver
```
To start the server, run `./bin/octoserver`
Server settings are in config.json.
Open a browser to [http://localhost:8000](http://localhost:8000) to start.
## Building and running the macOS app
You can build the Mac app on a Mac running macOS Catalina (10.15.6+) and with Xcode 12.0+. A valid development signing certificate must be available.
## Building and running standalone desktop apps
First build the server using the steps above, then run:
```
make mac
```
You can build standalone apps that package the server to run locally against SQLite:
To run, launch mac/dist/Tasks.app
* Mac:
* `make mac-app`
* run `mac/dist/Tasks.app`
* *Requires: macOS Catalina (10.15), Xcode 12 and a development signing certificate.*
* Linux:
* `make linux-app`
* run `linux/dist/tasks-app`
* Windows
* `make win-app`
* run `win/dist/tasks-win.exe`
* *Requires: Windows 10*
Cross-compilation currently isn't fully supported, so please build on the appropriate platform.

View File

@ -8,6 +8,7 @@ import (
"log"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/gorilla/mux"
@ -155,7 +156,21 @@ func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
blockID := vars["blockID"]
blocks, err := a.app().GetSubTree(blockID)
query := r.URL.Query()
levels, err := strconv.ParseInt(query.Get("l"), 10, 32)
if err != nil {
levels = 2
}
if levels != 2 && levels != 3 {
log.Printf(`ERROR Invalid levels: %d`, levels)
errorData := map[string]string{"description": "invalid levels"}
errorResponse(w, http.StatusInternalServerError, errorData)
return
}
blocks, err := a.app().GetSubTree(blockID, int(levels))
if err != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, nil)
@ -163,7 +178,7 @@ func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("GetSubTree blockID: %s, %d result(s)", blockID, len(blocks))
log.Printf("GetSubTree (%v) blockID: %s, %d result(s)", levels, blockID, len(blocks))
json, err := json.Marshal(blocks)
if err != nil {
log.Printf(`ERROR json.Marshal: %v`, r)
@ -302,7 +317,7 @@ func errorResponse(w http.ResponseWriter, code int, message map[string]string) {
data = []byte("{}")
}
w.WriteHeader(code)
fmt.Fprint(w, data)
w.Write(data)
}
func addUserID(rw http.ResponseWriter, req *http.Request, next http.Handler) {

View File

@ -44,16 +44,19 @@ func (a *App) InsertBlocks(blocks []model.Block) error {
return err
}
a.wsServer.BroadcastBlockChange(block)
go a.webhook.NotifyUpdate(block)
}
a.wsServer.BroadcastBlockChangeToWebsocketClients(blockIDsToNotify)
return nil
}
func (a *App) GetSubTree(blockID string) ([]model.Block, error) {
return a.store.GetSubTree(blockID)
func (a *App) GetSubTree(blockID string, levels int) ([]model.Block, error) {
// Only 2 or 3 levels are supported for now
if levels >= 3 {
return a.store.GetSubTree3(blockID)
}
return a.store.GetSubTree2(blockID)
}
func (a *App) GetAllBlocks() ([]model.Block, error) {
@ -76,7 +79,7 @@ func (a *App) DeleteBlock(blockID string) error {
return err
}
a.wsServer.BroadcastBlockChangeToWebsocketClients(blockIDsToNotify)
a.wsServer.BroadcastBlockDelete(blockID, parentID)
return nil
}

View File

@ -1,13 +1,13 @@
package app
import (
"crypto/rand"
"errors"
"fmt"
"io"
"log"
"path/filepath"
"strings"
"github.com/mattermost/mattermost-octo-tasks/server/utils"
)
func (a *App) SaveFile(reader io.Reader, filename string) (string, error) {
@ -17,7 +17,7 @@ func (a *App) SaveFile(reader io.Reader, filename string) (string, error) {
fileExtension = ".jpg"
}
createdFilename := fmt.Sprintf(`%s%s`, createGUID(), fileExtension)
createdFilename := fmt.Sprintf(`%s%s`, utils.CreateGUID(), fileExtension)
_, appErr := a.filesBackend.WriteFile(reader, createdFilename)
if appErr != nil {
@ -32,15 +32,3 @@ func (a *App) GetFilePath(filename string) string {
return filepath.Join(folderPath, filename)
}
// CreateGUID returns a random GUID.
func createGUID() string {
b := make([]byte, 16)
_, err := rand.Read(b)
if err != nil {
log.Fatal(err)
}
uuid := fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
return uuid
}

186
server/client/client.go Normal file
View File

@ -0,0 +1,186 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
"github.com/mattermost/mattermost-octo-tasks/server/model"
)
const (
API_URL_SUFFIX = "/api/v1"
)
type Response struct {
StatusCode int
Error error
Header http.Header
}
func BuildResponse(r *http.Response) *Response {
return &Response{
StatusCode: r.StatusCode,
Header: r.Header,
}
}
func BuildErrorResponse(r *http.Response, err error) *Response {
statusCode := 0
header := make(http.Header)
if r != nil {
statusCode = r.StatusCode
header = r.Header
}
return &Response{
StatusCode: statusCode,
Error: err,
Header: header,
}
}
func closeBody(r *http.Response) {
if r.Body != nil {
_, _ = io.Copy(ioutil.Discard, r.Body)
_ = r.Body.Close()
}
}
func toJSON(v interface{}) string {
b, _ := json.Marshal(v)
return string(b)
}
type Client struct {
Url string
ApiUrl string
HttpClient *http.Client
HttpHeader map[string]string
}
func NewClient(url string) *Client {
url = strings.TrimRight(url, "/")
return &Client{url, url + API_URL_SUFFIX, &http.Client{}, map[string]string{}}
}
func (c *Client) DoApiGet(url string, etag string) (*http.Response, error) {
return c.DoApiRequest(http.MethodGet, c.ApiUrl+url, "", etag)
}
func (c *Client) DoApiPost(url string, data string) (*http.Response, error) {
return c.DoApiRequest(http.MethodPost, c.ApiUrl+url, data, "")
}
func (c *Client) doApiPostBytes(url string, data []byte) (*http.Response, error) {
return c.doApiRequestBytes(http.MethodPost, c.ApiUrl+url, data, "")
}
func (c *Client) DoApiPut(url string, data string) (*http.Response, error) {
return c.DoApiRequest(http.MethodPut, c.ApiUrl+url, data, "")
}
func (c *Client) doApiPutBytes(url string, data []byte) (*http.Response, error) {
return c.doApiRequestBytes(http.MethodPut, c.ApiUrl+url, data, "")
}
func (c *Client) DoApiDelete(url string) (*http.Response, error) {
return c.DoApiRequest(http.MethodDelete, c.ApiUrl+url, "", "")
}
func (c *Client) DoApiRequest(method, url, data, etag string) (*http.Response, error) {
return c.doApiRequestReader(method, url, strings.NewReader(data), etag)
}
func (c *Client) doApiRequestBytes(method, url string, data []byte, etag string) (*http.Response, error) {
return c.doApiRequestReader(method, url, bytes.NewReader(data), etag)
}
func (c *Client) doApiRequestReader(method, url string, data io.Reader, etag string) (*http.Response, error) {
rq, err := http.NewRequest(method, url, data)
if err != nil {
return nil, err
}
if c.HttpHeader != nil && len(c.HttpHeader) > 0 {
for k, v := range c.HttpHeader {
rq.Header.Set(k, v)
}
}
rp, err := c.HttpClient.Do(rq)
if err != nil || rp == nil {
return nil, err
}
if rp.StatusCode == 304 {
return rp, nil
}
if rp.StatusCode >= 300 {
defer closeBody(rp)
b, err := ioutil.ReadAll(rp.Body)
if err != nil {
return rp, fmt.Errorf("error when parsing response with code %d: %w", rp.StatusCode, err)
}
return rp, fmt.Errorf(string(b))
}
return rp, nil
}
func (c *Client) GetBlocksRoute() string {
return "/blocks"
}
func (c *Client) GetBlockRoute(id string) string {
return fmt.Sprintf("%s/%s", c.GetBlocksRoute(), id)
}
func (c *Client) GetSubtreeRoute(id string) string {
return fmt.Sprintf("%s/subtree", c.GetBlockRoute(id))
}
func (c *Client) GetBlocks() ([]model.Block, *Response) {
r, err := c.DoApiGet(c.GetBlocksRoute(), "")
if err != nil {
return nil, BuildErrorResponse(r, err)
}
defer closeBody(r)
return model.BlocksFromJSON(r.Body), BuildResponse(r)
}
func (c *Client) InsertBlocks(blocks []model.Block) (bool, *Response) {
r, err := c.DoApiPost(c.GetBlocksRoute(), toJSON(blocks))
if err != nil {
return false, BuildErrorResponse(r, err)
}
defer closeBody(r)
return true, BuildResponse(r)
}
func (c *Client) DeleteBlock(blockID string) (bool, *Response) {
r, err := c.DoApiDelete(c.GetBlockRoute(blockID))
if err != nil {
return false, BuildErrorResponse(r, err)
}
defer closeBody(r)
return true, BuildResponse(r)
}
func (c *Client) GetSubtree(blockID string) ([]model.Block, *Response) {
r, err := c.DoApiGet(c.GetSubtreeRoute(blockID), "")
if err != nil {
return nil, BuildErrorResponse(r, err)
}
defer closeBody(r)
return model.BlocksFromJSON(r.Body), BuildResponse(r)
}

View File

@ -0,0 +1,218 @@
package integrationtests
import (
"testing"
"github.com/mattermost/mattermost-octo-tasks/server/model"
"github.com/mattermost/mattermost-octo-tasks/server/utils"
"github.com/stretchr/testify/require"
)
func TestGetBlocks(t *testing.T) {
th := SetupTestHelper().InitBasic()
defer th.TearDown()
blockID1 := utils.CreateGUID()
blockID2 := utils.CreateGUID()
newBlocks := []model.Block{
{
ID: blockID1,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
},
{
ID: blockID2,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
},
}
_, resp := th.Client.InsertBlocks(newBlocks)
require.NoError(t, resp.Error)
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 2)
blockIDs := make([]string, len(blocks))
for i, b := range blocks {
blockIDs[i] = b.ID
}
require.Contains(t, blockIDs, blockID1)
require.Contains(t, blockIDs, blockID2)
}
func TestPostBlock(t *testing.T) {
th := SetupTestHelper().InitBasic()
defer th.TearDown()
blockID1 := utils.CreateGUID()
blockID2 := utils.CreateGUID()
blockID3 := utils.CreateGUID()
t.Run("Create a single block", func(t *testing.T) {
block := model.Block{
ID: blockID1,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
Title: "New title",
}
_, resp := th.Client.InsertBlocks([]model.Block{block})
require.NoError(t, resp.Error)
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 1)
require.Equal(t, blockID1, blocks[0].ID)
})
t.Run("Create a couple of blocks in the same call", func(t *testing.T) {
newBlocks := []model.Block{
{
ID: blockID2,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
},
{
ID: blockID3,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
},
}
_, resp := th.Client.InsertBlocks(newBlocks)
require.NoError(t, resp.Error)
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 3)
blockIDs := make([]string, len(blocks))
for i, b := range blocks {
blockIDs[i] = b.ID
}
require.Contains(t, blockIDs, blockID1)
require.Contains(t, blockIDs, blockID2)
require.Contains(t, blockIDs, blockID3)
})
t.Run("Update a block", func(t *testing.T) {
block := model.Block{
ID: blockID1,
CreateAt: 1,
UpdateAt: 20,
Type: "board",
Title: "Updated title",
}
_, resp := th.Client.InsertBlocks([]model.Block{block})
require.NoError(t, resp.Error)
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 3)
var updatedBlock model.Block
for _, b := range blocks {
if b.ID == blockID1 {
updatedBlock = b
}
}
require.NotNil(t, updatedBlock)
require.Equal(t, "Updated title", updatedBlock.Title)
})
}
func TestDeleteBlock(t *testing.T) {
th := SetupTestHelper().InitBasic()
defer th.TearDown()
blockID := utils.CreateGUID()
t.Run("Create a block", func(t *testing.T) {
block := model.Block{
ID: blockID,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
Title: "New title",
}
_, resp := th.Client.InsertBlocks([]model.Block{block})
require.NoError(t, resp.Error)
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 1)
require.Equal(t, blockID, blocks[0].ID)
})
t.Run("Delete a block", func(t *testing.T) {
_, resp := th.Client.DeleteBlock(blockID)
require.NoError(t, resp.Error)
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 0)
})
}
func TestGetSubtree(t *testing.T) {
th := SetupTestHelper().InitBasic()
defer th.TearDown()
parentBlockID := utils.CreateGUID()
childBlockID1 := utils.CreateGUID()
childBlockID2 := utils.CreateGUID()
t.Run("Create the block structure", func(t *testing.T) {
newBlocks := []model.Block{
{
ID: parentBlockID,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
},
{
ID: childBlockID1,
ParentID: parentBlockID,
CreateAt: 2,
UpdateAt: 2,
Type: "card",
},
{
ID: childBlockID2,
ParentID: parentBlockID,
CreateAt: 2,
UpdateAt: 2,
Type: "card",
},
}
_, resp := th.Client.InsertBlocks(newBlocks)
require.NoError(t, resp.Error)
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 1)
require.Equal(t, parentBlockID, blocks[0].ID)
})
t.Run("Get subtree for parent ID", func(t *testing.T) {
blocks, resp := th.Client.GetSubtree(parentBlockID)
require.NoError(t, resp.Error)
require.Len(t, blocks, 3)
blockIDs := make([]string, len(blocks))
for i, b := range blocks {
blockIDs[i] = b.ID
}
require.Contains(t, blockIDs, parentBlockID)
require.Contains(t, blockIDs, childBlockID1)
require.Contains(t, blockIDs, childBlockID2)
})
}

View File

@ -0,0 +1,54 @@
package integrationtests
import (
"net/http"
"github.com/mattermost/mattermost-octo-tasks/server/client"
"github.com/mattermost/mattermost-octo-tasks/server/server"
"github.com/mattermost/mattermost-octo-tasks/server/services/config"
)
type TestHelper struct {
Server *server.Server
Client *client.Client
}
func getTestConfig() *config.Configuration {
return &config.Configuration{
ServerRoot: "http://localhost:8888",
Port: 8888,
DBType: "sqlite3",
DBConfigString: ":memory:",
WebPath: "./pack",
FilesPath: "./files",
}
}
func SetupTestHelper() *TestHelper {
th := &TestHelper{}
srv, err := server.New(getTestConfig())
if err != nil {
panic(err)
}
th.Server = srv
th.Client = client.NewClient(srv.Config().ServerRoot)
return th
}
func (th *TestHelper) InitBasic() *TestHelper {
go func() {
if err := th.Server.Start(); err != http.ErrServerClosed {
panic(err)
}
}()
return th
}
func (th *TestHelper) TearDown() {
err := th.Server.Shutdown()
if err != nil {
panic(err)
}
}

View File

@ -1,5 +1,10 @@
package model
import (
"encoding/json"
"io"
)
// Block is the basic data unit.
type Block struct {
ID string `json:"id"`
@ -12,3 +17,9 @@ type Block struct {
UpdateAt int64 `json:"updateAt"`
DeleteAt int64 `json:"deleteAt"`
}
func BlocksFromJSON(data io.Reader) []Block {
var blocks []Block
json.NewDecoder(data).Decode(&blocks)
return blocks
}

View File

@ -135,5 +135,13 @@ func (s *Server) Start() error {
}
func (s *Server) Shutdown() error {
if err := s.webServer.Shutdown(); err != nil {
return err
}
return s.store.Shutdown()
}
func (s *Server) Config() *config.Configuration {
return s.config
}

View File

@ -136,19 +136,34 @@ func (mr *MockStoreMockRecorder) GetParentID(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetParentID", reflect.TypeOf((*MockStore)(nil).GetParentID), arg0)
}
// GetSubTree mocks base method
func (m *MockStore) GetSubTree(arg0 string) ([]model.Block, error) {
// GetSubTree2 mocks base method
func (m *MockStore) GetSubTree2(arg0 string) ([]model.Block, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSubTree", arg0)
ret := m.ctrl.Call(m, "GetSubTree2", arg0)
ret0, _ := ret[0].([]model.Block)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetSubTree indicates an expected call of GetSubTree
func (mr *MockStoreMockRecorder) GetSubTree(arg0 interface{}) *gomock.Call {
// GetSubTree2 indicates an expected call of GetSubTree2
func (mr *MockStoreMockRecorder) GetSubTree2(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree", reflect.TypeOf((*MockStore)(nil).GetSubTree), arg0)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree2", reflect.TypeOf((*MockStore)(nil).GetSubTree2), arg0)
}
// GetSubTree3 mocks base method
func (m *MockStore) GetSubTree3(arg0 string) ([]model.Block, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSubTree3", arg0)
ret0, _ := ret[0].([]model.Block)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetSubTree3 indicates an expected call of GetSubTree3
func (mr *MockStoreMockRecorder) GetSubTree3(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree3", reflect.TypeOf((*MockStore)(nil).GetSubTree3), arg0)
}
// GetSystemSettings mocks base method

View File

@ -15,7 +15,10 @@ import (
func (s *SQLStore) latestsBlocksSubquery() sq.SelectBuilder {
internalQuery := sq.Select("*", "ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn").From("blocks")
return sq.Select("*").FromSelect(internalQuery, "a").Where(sq.Eq{"rn": 1})
return sq.Select("*").
FromSelect(internalQuery, "a").
Where(sq.Eq{"rn": 1}).
Where(sq.Eq{"delete_at": 0})
}
func (s *SQLStore) GetBlocksWithParentAndType(parentID string, blockType string) ([]model.Block, error) {
@ -24,7 +27,6 @@ func (s *SQLStore) GetBlocksWithParentAndType(parentID string, blockType string)
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest").
Where(sq.Eq{"delete_at": 0}).
Where(sq.Eq{"parent_id": parentID}).
Where(sq.Eq{"type": blockType})
@ -44,7 +46,6 @@ func (s *SQLStore) GetBlocksWithParent(parentID string) ([]model.Block, error) {
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest").
Where(sq.Eq{"delete_at": 0}).
Where(sq.Eq{"parent_id": parentID})
rows, err := query.Query()
@ -63,7 +64,6 @@ func (s *SQLStore) GetBlocksWithType(blockType string) ([]model.Block, error) {
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest").
Where(sq.Eq{"delete_at": 0}).
Where(sq.Eq{"type": blockType})
rows, err := query.Query()
@ -76,13 +76,13 @@ func (s *SQLStore) GetBlocksWithType(blockType string) ([]model.Block, error) {
return blocksFromRows(rows)
}
func (s *SQLStore) GetSubTree(blockID 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",
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest").
Where(sq.Eq{"delete_at": 0}).
Where(sq.Or{sq.Eq{"id": blockID}, sq.Eq{"parent_id": blockID}})
rows, err := query.Query()
@ -95,13 +95,44 @@ func (s *SQLStore) GetSubTree(blockID string) ([]model.Block, error) {
return blocksFromRows(rows)
}
// 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",
"l3.fields", "l3.create_at", "l3.update_at",
"l3.delete_at").
FromSelect(s.latestsBlocksSubquery(), "l1").
JoinClause(s.latestsBlocksSubquery().Prefix("JOIN (").Suffix(") l2 on l2.parent_id = l1.id or l2.id = l1.id")).
JoinClause(s.latestsBlocksSubquery().Prefix("JOIN (").Suffix(") l3 on l3.parent_id = l2.id or l3.id = l2.id")).
Where(sq.Eq{"l1.id": blockID})
// This second subquery is used to return distinct blocks
// We can't use DISTINCT because JSON columns in Postgres don't support it, and SQLite doesn't support DISTINCT ON
subquery2 := sq.Select("*", "ROW_NUMBER() OVER (PARTITION BY id) AS rn").
FromSelect(subquery1, "sub1")
query := s.getQueryBuilder().Select("id", "parent_id", "schema", "type", "title",
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at").
FromSelect(subquery2, "sub2").
Where(sq.Eq{"rn": 1})
rows, err := query.Query()
if err != nil {
log.Printf(`getSubTree3 ERROR: %v`, err)
return nil, err
}
return blocksFromRows(rows)
}
func (s *SQLStore) GetAllBlocks() ([]model.Block, error) {
query := s.getQueryBuilder().
Select("id", "parent_id", "schema", "type", "title",
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest").
Where(sq.Eq{"delete_at": 0})
FromSelect(s.latestsBlocksSubquery(), "latest")
rows, err := query.Query()
if err != nil {
@ -156,7 +187,6 @@ func blocksFromRows(rows *sql.Rows) ([]model.Block, error) {
func (s *SQLStore) GetParentID(blockID string) (string, error) {
query := s.getQueryBuilder().Select("parent_id").
FromSelect(s.latestsBlocksSubquery(), "latest").
Where(sq.Eq{"delete_at": 0}).
Where(sq.Eq{"id": blockID})
row := query.QueryRow()

View File

@ -36,3 +36,109 @@ func TestInsertBlock(t *testing.T) {
require.NoError(t, err)
require.Empty(t, blocks)
}
func TestGetSubTree2(t *testing.T) {
store, tearDown := SetupTests(t)
defer tearDown()
blocks, err := store.GetAllBlocks()
require.NoError(t, err)
require.Empty(t, blocks)
blocksToInsert := []model.Block{
model.Block{
ID: "parent",
},
model.Block{
ID: "child1",
ParentID: "parent",
},
model.Block{
ID: "child2",
ParentID: "parent",
},
model.Block{
ID: "grandchild1",
ParentID: "child1",
},
model.Block{
ID: "grandchild2",
ParentID: "child2",
},
model.Block{
ID: "greatgrandchild1",
ParentID: "grandchild1",
},
}
InsertBlocks(t, store, blocksToInsert)
blocks, err = store.GetSubTree2("parent")
require.NoError(t, err)
require.Len(t, blocks, 3)
require.True(t, ContainsBlockWithID(blocks, "parent"))
require.True(t, ContainsBlockWithID(blocks, "child1"))
require.True(t, ContainsBlockWithID(blocks, "child2"))
// Wait for not colliding the ID+insert_at key
time.Sleep(1 * time.Millisecond)
DeleteBlocks(t, store, blocksToInsert)
blocks, err = store.GetAllBlocks()
require.NoError(t, err)
require.Empty(t, blocks)
}
func TestGetSubTree3(t *testing.T) {
store, tearDown := SetupTests(t)
defer tearDown()
blocks, err := store.GetAllBlocks()
require.NoError(t, err)
require.Empty(t, blocks)
blocksToInsert := []model.Block{
model.Block{
ID: "parent",
},
model.Block{
ID: "child1",
ParentID: "parent",
},
model.Block{
ID: "child2",
ParentID: "parent",
},
model.Block{
ID: "grandchild1",
ParentID: "child1",
},
model.Block{
ID: "grandchild2",
ParentID: "child2",
},
model.Block{
ID: "greatgrandchild1",
ParentID: "grandchild1",
},
}
InsertBlocks(t, store, blocksToInsert)
blocks, err = store.GetSubTree3("parent")
require.NoError(t, err)
require.Len(t, blocks, 5)
require.True(t, ContainsBlockWithID(blocks, "parent"))
require.True(t, ContainsBlockWithID(blocks, "child1"))
require.True(t, ContainsBlockWithID(blocks, "child2"))
require.True(t, ContainsBlockWithID(blocks, "grandchild1"))
require.True(t, ContainsBlockWithID(blocks, "grandchild2"))
// Wait for not colliding the ID+insert_at key
time.Sleep(1 * time.Millisecond)
DeleteBlocks(t, store, blocksToInsert)
blocks, err = store.GetAllBlocks()
require.NoError(t, err)
require.Empty(t, blocks)
}

View File

@ -4,6 +4,7 @@ import (
"os"
"testing"
"github.com/mattermost/mattermost-octo-tasks/server/model"
"github.com/stretchr/testify/require"
)
@ -28,3 +29,27 @@ func SetupTests(t *testing.T) (*SQLStore, func()) {
return store, tearDown
}
func InsertBlocks(t *testing.T, s *SQLStore, blocks []model.Block) {
for _, block := range blocks {
err := s.InsertBlock(block)
require.NoError(t, err)
}
}
func DeleteBlocks(t *testing.T, s *SQLStore, blocks []model.Block) {
for _, block := range blocks {
err := s.DeleteBlock(block.ID)
require.NoError(t, err)
}
}
func ContainsBlockWithID(blocks []model.Block, blockID string) bool {
for _, block := range blocks {
if block.ID == blockID {
return true
}
}
return false
}

View File

@ -8,7 +8,8 @@ type Store interface {
GetBlocksWithParentAndType(parentID string, blockType string) ([]model.Block, error)
GetBlocksWithParent(parentID string) ([]model.Block, error)
GetBlocksWithType(blockType string) ([]model.Block, error)
GetSubTree(blockID string) ([]model.Block, error)
GetSubTree2(blockID string) ([]model.Block, error)
GetSubTree3(blockID string) ([]model.Block, error)
GetAllBlocks() ([]model.Block, error)
GetParentID(blockID string) (string, error)
InsertBlock(block model.Block) error

19
server/utils/utils.go Normal file
View File

@ -0,0 +1,19 @@
package utils
import (
"crypto/rand"
"fmt"
"log"
)
// CreateGUID returns a random GUID.
func CreateGUID() string {
b := make([]byte, 16)
_, err := rand.Read(b)
if err != nil {
log.Fatal(err)
}
uuid := fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
return uuid
}

View File

@ -20,7 +20,8 @@ type RoutedService interface {
// Server is the structure responsible for managing our http web server.
type Server struct {
router *mux.Router
http.Server
rootPath string
port int
ssl bool
@ -31,7 +32,10 @@ func NewServer(rootPath string, port int, ssl bool) *Server {
r := mux.NewRouter()
ws := &Server{
router: r,
Server: http.Server{
Addr: fmt.Sprintf(`:%d`, port),
Handler: r,
},
rootPath: rootPath,
port: port,
ssl: ssl,
@ -40,14 +44,18 @@ func NewServer(rootPath string, port int, ssl bool) *Server {
return ws
}
func (ws *Server) Router() *mux.Router {
return ws.Server.Handler.(*mux.Router)
}
// AddRoutes allows services to register themself in the webserver router and provide new endpoints.
func (ws *Server) AddRoutes(rs RoutedService) {
rs.RegisterRoutes(ws.router)
rs.RegisterRoutes(ws.Router())
}
func (ws *Server) registerRoutes() {
ws.router.PathPrefix("/static").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(ws.rootPath, "static")))))
ws.router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ws.Router().PathPrefix("/static").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(ws.rootPath, "static")))))
ws.Router().PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeFile(w, r, path.Join(ws.rootPath, "index.html"))
})
@ -56,14 +64,11 @@ func (ws *Server) registerRoutes() {
// Start runs the web server and start listening for charsetnnections.
func (ws *Server) Start() error {
ws.registerRoutes()
http.Handle("/", ws.router)
urlPort := fmt.Sprintf(`:%d`, ws.port)
isSSL := ws.ssl && fileExists("./cert/cert.pem") && fileExists("./cert/key.pem")
if isSSL {
log.Println("https server started on ", urlPort)
err := http.ListenAndServeTLS(urlPort, "./cert/cert.pem", "./cert/key.pem", nil)
log.Printf("https server started on :%d\n", ws.port)
err := ws.ListenAndServeTLS("./cert/cert.pem", "./cert/key.pem")
if err != nil {
return err
}
@ -71,8 +76,8 @@ func (ws *Server) Start() error {
return nil
}
log.Println("http server started on ", urlPort)
err := http.ListenAndServe(urlPort, nil)
log.Printf("http server started on :%d\n", ws.port)
err := ws.ListenAndServe()
if err != nil {
return err
}
@ -80,6 +85,10 @@ func (ws *Server) Start() error {
return nil
}
func (ws *Server) Shutdown() error {
return ws.Close()
}
// fileExists returns true if a file exists at the path.
func fileExists(path string) bool {
_, err := os.Stat(path)

View File

@ -5,9 +5,11 @@ import (
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/mattermost/mattermost-octo-tasks/server/model"
)
// RegisterRoutes registers routes.
@ -98,10 +100,10 @@ func NewServer() *Server {
}
}
// WebsocketMsg is sent on block changes.
type WebsocketMsg struct {
Action string `json:"action"`
BlockID string `json:"blockId"`
// UpdateMsg is sent on block updates
type UpdateMsg struct {
Action string `json:"action"`
Block model.Block `json:"block"`
}
// WebsocketCommand is an incoming command from the client.
@ -166,16 +168,30 @@ func (ws *Server) handleWebSocketOnChange(w http.ResponseWriter, r *http.Request
}
}
// BroadcastBlockChangeToWebsocketClients broadcasts change to clients.
func (ws *Server) BroadcastBlockChangeToWebsocketClients(blockIDs []string) {
for _, blockID := range blockIDs {
// BroadcastBlockDelete broadcasts delete messages to clients
func (ws *Server) BroadcastBlockDelete(blockID string, parentID string) {
now := time.Now().Unix()
block := model.Block{}
block.ID = blockID
block.ParentID = parentID
block.UpdateAt = now
block.DeleteAt = now
ws.BroadcastBlockChange(block)
}
// BroadcastBlockChange broadcasts update messages to clients
func (ws *Server) BroadcastBlockChange(block model.Block) {
blockIDsToNotify := []string{block.ID, block.ParentID}
for _, blockID := range blockIDsToNotify {
listeners := ws.GetListeners(blockID)
log.Printf("%d listener(s) for blockID: %s", len(listeners), blockID)
if listeners != nil {
message := WebsocketMsg{
Action: "UPDATE_BLOCK",
BlockID: blockID,
message := UpdateMsg{
Action: "UPDATE_BLOCK",
Block: block,
}
for _, listener := range listeners {

View File

@ -26,7 +26,7 @@
},
"rules": {
"no-unused-expressions": 0,
"babel/no-unused-expressions": 2,
"babel/no-unused-expressions": [2, {"allowShortCircuit": true}],
"eol-last": ["error", "always"],
"import/no-unresolved": 2,
"import/order": [
@ -110,6 +110,7 @@
"SwitchCase": 0
}
],
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": [
2,
{
@ -118,6 +119,8 @@
"variables": false
}
],
"no-useless-constructor": 0,
"@typescript-eslint/no-useless-constructor": 2,
"react/jsx-filename-extension": 0
}
},

View File

@ -6,7 +6,6 @@
"BoardComponent.delete": "Delete",
"BoardComponent.hidden-columns": "Hidden Columns",
"BoardComponent.hide": "Hide",
"BoardComponent.loading": "Loading...",
"BoardComponent.neww": "+ New",
"BoardComponent.no-property": "No {property}",
"BoardComponent.no-property-title": "Items with an empty {property} property will go here. This column cannot be removed.",
@ -16,10 +15,8 @@
"CardDetail.add-property": "+ Add a property",
"CardDetail.image": "Image",
"CardDetail.new-comment-placeholder": "Add a comment...",
"CardDetail.pick-icon": "Pick Icon",
"CardDetail.random-icon": "Random",
"CardDetail.remove-icon": "Remove Icon",
"CardDetail.text": "Text",
"CardDialog.editing-template": "You're editing a template",
"Comment.delete": "Delete",
"CommentsList.send": "Send",
"Filter.includes": "includes",
@ -31,6 +28,7 @@
"Sidebar.add-board": "+ Add Board",
"Sidebar.dark-theme": "Dark Theme",
"Sidebar.delete-board": "Delete Board",
"Sidebar.duplicate-board": "Duplicate Board",
"Sidebar.english": "English",
"Sidebar.export-archive": "Export Archive",
"Sidebar.import-archive": "Import Archive",
@ -44,7 +42,6 @@
"Sidebar.untitled-board": "(Untitled Board)",
"Sidebar.untitled-view": "(Untitled View)",
"TableComponent.add-icon": "Add Icon",
"TableComponent.loading": "Loading...",
"TableComponent.name": "Name",
"TableComponent.plus-new": "+ New",
"TableHeaderMenu.delete": "Delete",
@ -55,6 +52,12 @@
"TableHeaderMenu.sort-ascending": "Sort ascending",
"TableHeaderMenu.sort-descending": "Sort descending",
"TableRow.open": "Open",
"View.NewBoardTitle": "Board View",
"View.NewTableTitle": "Table View",
"ViewHeader.add-template": "+ New template",
"ViewHeader.delete-template": "Delete",
"ViewHeader.edit-template": "Edit",
"ViewHeader.empty-card": "Empty card",
"ViewHeader.export-board-archive": "Export Board Archive",
"ViewHeader.export-csv": "Export to CSV",
"ViewHeader.filter": "Filter",
@ -63,10 +66,13 @@
"ViewHeader.properties": "Properties",
"ViewHeader.search": "Search",
"ViewHeader.search-text": "Search text",
"ViewHeader.select-a-template": "Select a template",
"ViewHeader.sort": "Sort",
"ViewHeader.test-add-100-cards": "TEST: Add 100 cards",
"ViewHeader.test-add-1000-cards": "TEST: Add 1,000 cards",
"ViewHeader.test-distribute-cards": "TEST: Distribute cards",
"ViewHeader.test-randomize-icons": "TEST: Randomize icons",
"ViewHeader.untitled": "Untitled",
"ViewTitle.pick-icon": "Pick Icon",
"ViewTitle.random-icon": "Random",
"ViewTitle.remove-icon": "Remove Icon",

View File

@ -58,7 +58,7 @@ class Archiver {
input.type = 'file'
input.accept = '.octo'
input.onchange = async () => {
const file = input.files[0]
const file = input.files && input.files[0]
const contents = await (new Response(file)).text()
Utils.log(`Import ${contents.length} bytes.`)
const archive: Archive = JSON.parse(contents)

View File

@ -2,13 +2,15 @@
// See LICENSE.txt for license information.
import {Utils} from '../utils'
type BlockTypes = 'board' | 'view' | 'card' | 'text' | 'image' | 'divider' | 'comment'
interface IBlock {
readonly id: string
readonly parentId: string
readonly schema: number
readonly type: string
readonly title?: string
readonly type: BlockTypes
readonly title: string
readonly fields: Readonly<Record<string, any>>
readonly createAt: number
@ -21,8 +23,8 @@ interface IMutableBlock extends IBlock {
parentId: string
schema: number
type: string
title?: string
type: BlockTypes
title: string
fields: Record<string, any>
createAt: number
@ -34,19 +36,18 @@ class MutableBlock implements IMutableBlock {
id: string = Utils.createGuid()
schema: number
parentId: string
type: string
type: BlockTypes
title: string
fields: Record<string, any> = {}
createAt: number = Date.now()
updateAt = 0
deleteAt = 0
static duplicate(block: IBlock): IBlock {
static duplicate(block: IBlock): IMutableBlock {
const now = Date.now()
const newBlock = new MutableBlock(block)
newBlock.id = Utils.createGuid()
newBlock.title = `Copy of ${block.title}`
newBlock.createAt = now
newBlock.updateAt = now
newBlock.deleteAt = 0
@ -55,18 +56,17 @@ class MutableBlock implements IMutableBlock {
}
constructor(block: any = {}) {
const now = Date.now()
this.id = block.id || Utils.createGuid()
this.schema = 1
this.parentId = block.parentId
this.type = block.type
this.parentId = block.parentId || ''
this.type = block.type || ''
// Shallow copy here. Derived classes must make deep copies of their known properties in their constructors.
this.fields = block.fields ? {...block.fields} : {}
this.title = block.title
this.title = block.title || ''
const now = Date.now()
this.createAt = block.createAt || now
this.updateAt = block.updateAt || now
this.deleteAt = block.deleteAt || 0

View File

@ -57,6 +57,7 @@ class MutableBoard extends MutableBlock {
super(block)
this.type = 'board'
this.icon = block.fields?.icon || ''
if (block.fields?.cardProperties) {
// Deep clone of card properties and their options
this.cardProperties = block.fields.cardProperties.map((o: IPropertyTemplate) => {

View File

@ -10,17 +10,17 @@ type ISortOption = { propertyId: '__title' | string, reversed: boolean }
interface BoardView extends IBlock {
readonly viewType: IViewType
readonly groupById: string
readonly groupById?: string
readonly sortOptions: readonly ISortOption[]
readonly visiblePropertyIds: readonly string[]
readonly visibleOptionIds: readonly string[]
readonly hiddenOptionIds: readonly string[]
readonly filter: FilterGroup | undefined
readonly filter: FilterGroup
readonly cardOrder: readonly string[]
readonly columnWidths: Readonly<Record<string, number>>
}
class MutableBoardView extends MutableBlock {
class MutableBoardView extends MutableBlock implements BoardView {
get viewType(): IViewType {
return this.fields.viewType
}
@ -63,10 +63,10 @@ class MutableBoardView extends MutableBlock {
this.fields.hiddenOptionIds = value
}
get filter(): FilterGroup | undefined {
get filter(): FilterGroup {
return this.fields.filter
}
set filter(value: FilterGroup | undefined) {
set filter(value: FilterGroup) {
this.fields.filter = value
}

View File

@ -1,12 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Utils} from '../utils'
import {IBlock} from '../blocks/block'
import {MutableBlock} from './block'
interface Card extends IBlock {
readonly icon: string
readonly isTemplate: boolean
readonly properties: Readonly<Record<string, string>>
duplicate(): MutableCard
}
class MutableCard extends MutableBlock {
@ -17,6 +20,13 @@ class MutableCard extends MutableBlock {
this.fields.icon = value
}
get isTemplate(): boolean {
return this.fields.isTemplate as boolean
}
set isTemplate(value: boolean) {
this.fields.isTemplate = value
}
get properties(): Record<string, string> {
return this.fields.properties as Record<string, string>
}
@ -28,8 +38,15 @@ class MutableCard extends MutableBlock {
super(block)
this.type = 'card'
this.icon = block.fields?.icon || ''
this.properties = {...(block.fields?.properties || {})}
}
duplicate(): MutableCard {
const card = new MutableCard(this)
card.id = Utils.createGuid()
return card
}
}
export {MutableCard, Card}

View File

@ -17,6 +17,7 @@ class MutableImageBlock extends MutableOrderedBlock implements IOrderedBlock {
constructor(block: any = {}) {
super(block)
this.type = 'image'
this.url = block.fields?.url || ''
}
}

View File

@ -18,6 +18,7 @@ class MutableOrderedBlock extends MutableBlock implements IOrderedBlock {
constructor(block: any = {}) {
super(block)
this.order = block.fields?.order || 0
}
}

View File

@ -71,8 +71,12 @@ class CardFilter {
return true
}
static propertiesThatMeetFilterGroup(filterGroup: FilterGroup, templates: readonly IPropertyTemplate[]): Record<string, string> {
static propertiesThatMeetFilterGroup(filterGroup: FilterGroup | undefined, templates: readonly IPropertyTemplate[]): Record<string, string> {
// TODO: Handle filter groups
if (!filterGroup) {
return {}
}
const filters = filterGroup.filters.filter((o) => !FilterGroup.isAnInstanceOf(o))
if (filters.length < 1) {
return {}
@ -82,19 +86,30 @@ class CardFilter {
// Just need to meet the first clause
const property = this.propertyThatMeetsFilterClause(filters[0] as FilterClause, templates)
const result: Record<string, string> = {}
result[property.id] = property.value
if (property.value) {
result[property.id] = property.value
}
return result
}
// And: Need to meet all clauses
const result: Record<string, string> = {}
filters.forEach((filterClause) => {
const p = this.propertyThatMeetsFilterClause(filterClause as FilterClause, templates)
result[p.id] = p.value
const property = this.propertyThatMeetsFilterClause(filterClause as FilterClause, templates)
if (property.value) {
result[property.id] = property.value
}
})
return result
}
static propertyThatMeetsFilterClause(filterClause: FilterClause, templates: readonly IPropertyTemplate[]): { id: string, value?: string } {
const template = templates.find((o) => o.id === filterClause.propertyId)
if (!template) {
Utils.assertFailure(`propertyThatMeetsFilterClause. Cannot find template: ${filterClause.propertyId}`)
return {id: filterClause.propertyId}
}
switch (filterClause.condition) {
case 'includes': {
if (filterClause.values.length < 1) {
@ -108,7 +123,12 @@ class CardFilter {
}
if (template.type === 'select') {
const option = template.options.find((o) => !filterClause.values.includes(o.id))
return {id: filterClause.propertyId, value: option.id}
if (option) {
return {id: filterClause.propertyId, value: option.id}
}
// No other options exist
return {id: filterClause.propertyId}
}
// TODO: Handle non-select types

View File

@ -7,12 +7,11 @@ import {BlockIcons} from '../blockIcons'
import {Board} from '../blocks/board'
import {Card} from '../blocks/card'
import mutator from '../mutator'
import EmojiPicker from '../widgets/emojiPicker'
import DeleteIcon from '../widgets/icons/delete'
import EmojiIcon from '../widgets/icons/emoji'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import EmojiPicker from '../widgets/emojiPicker'
import EmojiIcon from '../widgets/icons/emoji'
import DeleteIcon from '../widgets/icons/delete'
import './blockIconSelector.scss'
type Props = {
@ -37,7 +36,7 @@ class BlockIconSelector extends React.Component<Props> {
document.body.click()
}
render(): JSX.Element {
render(): JSX.Element | null {
const {block, intl, size} = this.props
if (!block.icon) {
return null
@ -64,7 +63,7 @@ class BlockIconSelector extends React.Component<Props> {
id='remove'
icon={<DeleteIcon/>}
name={intl.formatMessage({id: 'ViewTitle.remove-icon', defaultMessage: 'Remove Icon'})}
onClick={() => mutator.changeIcon(block, undefined, 'remove icon')}
onClick={() => mutator.changeIcon(block, '', 'remove icon')}
/>
</Menu>
</MenuWrapper>

View File

@ -3,21 +3,18 @@
import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {MutableBlock} from '../blocks/block'
import {IPropertyTemplate} from '../blocks/board'
import {Card} from '../blocks/card'
import mutator from '../mutator'
import MenuWrapper from '../widgets/menuWrapper'
import Menu from '../widgets/menu'
import OptionsIcon from '../widgets/icons/options'
import IconButton from '../widgets/buttons/iconButton'
import DeleteIcon from '../widgets/icons/delete'
import DuplicateIcon from '../widgets/icons/duplicate'
import IconButton from '../widgets/buttons/iconButton'
import PropertyValueElement from './propertyValueElement'
import OptionsIcon from '../widgets/icons/options'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import './boardCard.scss'
import PropertyValueElement from './propertyValueElement'
type BoardCardProps = {
card: Card
@ -25,9 +22,9 @@ type BoardCardProps = {
isSelected: boolean
isDropZone?: boolean
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
onDragStart?: (e: React.DragEvent<HTMLDivElement>) => void
onDragEnd?: (e: React.DragEvent<HTMLDivElement>) => void
onDrop?: (e: React.DragEvent<HTMLDivElement>) => void
onDragStart: (e: React.DragEvent<HTMLDivElement>) => void
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void
onDrop: (e: React.DragEvent<HTMLDivElement>) => void
intl: IntlShape
}
@ -69,13 +66,17 @@ class BoardCard extends React.Component<BoardCardProps, BoardCardState> {
this.props.onDragEnd(e)
}}
onDragOver={(e) => {
this.setState({isDragOver: true})
onDragOver={() => {
if (!this.state.isDragOver) {
this.setState({isDragOver: true})
}
}}
onDragEnter={(e) => {
this.setState({isDragOver: true})
onDragEnter={() => {
if (!this.state.isDragOver) {
this.setState({isDragOver: true})
}
}}
onDragLeave={(e) => {
onDragLeave={() => {
this.setState({isDragOver: false})
}}
onDrop={(e) => {
@ -101,7 +102,9 @@ class BoardCard extends React.Component<BoardCardProps, BoardCardState> {
icon={<DuplicateIcon/>}
id='duplicate'
name={intl.formatMessage({id: 'BoardCard.duplicate', defaultMessage: 'Duplicate'})}
onClick={() => mutator.insertBlock(MutableBlock.duplicate(card), 'duplicate card')}
onClick={() => {
mutator.duplicateCard(card.id)
}}
/>
</Menu>
</MenuWrapper>

View File

@ -3,44 +3,45 @@
import React from 'react'
type Props = {
onDrop?: (e: React.DragEvent<HTMLDivElement>) => void
isDropZone?: boolean
onDrop: (e: React.DragEvent<HTMLDivElement>) => void
isDropZone: boolean
}
type State = {
isDragOver?: boolean
dragPageX?: number
dragPageY?: number
isDragOver: boolean
}
class BoardColumn extends React.Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {}
class BoardColumn extends React.PureComponent<Props, State> {
state = {
isDragOver: false,
}
render() {
render(): JSX.Element {
let className = 'octo-board-column'
if (this.props.isDropZone && this.state.isDragOver) {
className += ' dragover'
}
const element =
(<div
const element = (
<div
className={className}
onDragOver={(e) => {
e.preventDefault()
this.setState({isDragOver: true, dragPageX: e.pageX, dragPageY: e.pageY})
if (!this.state.isDragOver) {
this.setState({isDragOver: true})
}
}}
onDragEnter={(e) => {
e.preventDefault()
this.setState({isDragOver: true, dragPageX: e.pageX, dragPageY: e.pageY})
if (!this.state.isDragOver) {
this.setState({isDragOver: true})
}
}}
onDragLeave={(e) => {
e.preventDefault()
this.setState({isDragOver: false, dragPageX: undefined, dragPageY: undefined})
this.setState({isDragOver: false})
}}
onDrop={(e) => {
this.setState({isDragOver: false, dragPageX: undefined, dragPageY: undefined})
this.setState({isDragOver: false})
if (this.props.isDropZone) {
this.props.onDrop(e)
}

View File

@ -2,46 +2,47 @@
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import React from 'react'
import {injectIntl, IntlShape, FormattedMessage} from 'react-intl'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {BlockIcons} from '../blockIcons'
import {IBlock} from '../blocks/block'
import {IPropertyOption, IPropertyTemplate} from '../blocks/board'
import {Card, MutableCard} from '../blocks/card'
import {BoardTree, BoardTreeGroup} from '../viewModel/boardTree'
import {CardFilter} from '../cardFilter'
import {Constants} from '../constants'
import mutator from '../mutator'
import {Utils} from '../utils'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import OptionsIcon from '../widgets/icons/options'
import AddIcon from '../widgets/icons/add'
import HideIcon from '../widgets/icons/hide'
import ShowIcon from '../widgets/icons/show'
import DeleteIcon from '../widgets/icons/delete'
import {BoardTree, BoardTreeGroup} from '../viewModel/boardTree'
import {MutableCardTree} from '../viewModel/cardTree'
import Button from '../widgets/buttons/button'
import IconButton from '../widgets/buttons/iconButton'
import AddIcon from '../widgets/icons/add'
import DeleteIcon from '../widgets/icons/delete'
import HideIcon from '../widgets/icons/hide'
import OptionsIcon from '../widgets/icons/options'
import ShowIcon from '../widgets/icons/show'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import BoardCard from './boardCard'
import {BoardColumn} from './boardColumn'
import './boardComponent.scss'
import {CardDialog} from './cardDialog'
import {Editable} from './editable'
import RootPortal from './rootPortal'
import ViewHeader from './viewHeader'
import ViewTitle from './viewTitle'
import './boardComponent.scss'
type Props = {
boardTree?: BoardTree
boardTree: BoardTree
showView: (id: string) => void
setSearchText: (text: string) => void
setSearchText: (text?: string) => void
intl: IntlShape
}
type State = {
isSearching: boolean
shownCard?: Card
shownCardId?: string
viewMenu: boolean
selectedCardIds: string[]
showFilter: boolean
@ -49,7 +50,7 @@ type State = {
class BoardComponent extends React.Component<Props, State> {
private draggedCards: Card[] = []
private draggedHeaderOption: IPropertyOption
private draggedHeaderOption?: IPropertyOption
private backgroundRef = React.createRef<HTMLDivElement>()
private searchFieldRef = React.createRef<Editable>()
@ -83,7 +84,7 @@ class BoardComponent extends React.Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
isSearching: Boolean(this.props.boardTree?.getSearchText()),
isSearching: Boolean(this.props.boardTree.getSearchText()),
viewMenu: false,
selectedCardIds: [],
showFilter: false,
@ -96,25 +97,20 @@ class BoardComponent extends React.Component<Props, State> {
componentDidUpdate(prevPros: Props, prevState: State): void {
if (this.state.isSearching && !prevState.isSearching) {
this.searchFieldRef.current.focus()
this.searchFieldRef.current?.focus()
}
}
render(): JSX.Element {
const {boardTree, showView} = this.props
const {groupByProperty} = boardTree
if (!boardTree || !boardTree.board) {
return (
<div>
<FormattedMessage
id='BoardComponent.loading'
defaultMessage='Loading...'
/>
</div>
)
if (!groupByProperty) {
Utils.assertFailure('Board views must have groupByProperty set')
return <div/>
}
const propertyValues = boardTree.groupByProperty?.options || []
const propertyValues = groupByProperty.options || []
Utils.log(`${propertyValues.length} propertyValues`)
const {board, activeView, visibleGroups, hiddenGroups} = boardTree
@ -129,12 +125,14 @@ class BoardComponent extends React.Component<Props, State> {
this.backgroundClicked(e)
}}
>
{this.state.shownCard &&
{this.state.shownCardId &&
<RootPortal>
<CardDialog
key={this.state.shownCardId}
boardTree={boardTree}
card={this.state.shownCard}
onClose={() => this.setState({shownCard: undefined})}
cardId={this.state.shownCardId}
onClose={() => this.setState({shownCardId: undefined})}
showCard={(cardId) => this.setState({shownCardId: cardId})}
/>
</RootPortal>}
@ -150,6 +148,9 @@ class BoardComponent extends React.Component<Props, State> {
showView={showView}
setSearchText={this.props.setSearchText}
addCard={() => this.addCard()}
addCardFromTemplate={this.addCardFromTemplate}
addCardTemplate={this.addCardTemplate}
editCardTemplate={this.editCardTemplate}
withGroupBy={true}
/>
<div
@ -237,7 +238,11 @@ class BoardComponent extends React.Component<Props, State> {
this.cardClicked(e, card)
}}
onDragStart={() => {
this.draggedCards = this.state.selectedCardIds.includes(card.id) ? this.state.selectedCardIds.map((id) => boardTree.allCards.find((o) => o.id === id)) : [card]
if (this.state.selectedCardIds.includes(card.id)) {
this.draggedCards = this.state.selectedCardIds.map((id) => boardTree.allCards.find((o) => o.id === id)!)
} else {
this.draggedCards = [card]
}
}}
onDragEnd={() => {
this.draggedCards = []
@ -274,19 +279,19 @@ class BoardComponent extends React.Component<Props, State> {
}}
onDragOver={(e) => {
ref.current.classList.add('dragover')
ref.current!.classList.add('dragover')
e.preventDefault()
}}
onDragEnter={(e) => {
ref.current.classList.add('dragover')
ref.current!.classList.add('dragover')
e.preventDefault()
}}
onDragLeave={(e) => {
ref.current.classList.remove('dragover')
ref.current!.classList.remove('dragover')
e.preventDefault()
}}
onDrop={(e) => {
ref.current.classList.remove('dragover')
ref.current!.classList.remove('dragover')
e.preventDefault()
this.onDropToColumn(group.option)
}}
@ -296,13 +301,13 @@ class BoardComponent extends React.Component<Props, State> {
title={intl.formatMessage({
id: 'BoardComponent.no-property-title',
defaultMessage: 'Items with an empty {property} property will go here. This column cannot be removed.',
}, {property: boardTree.groupByProperty?.name})}
}, {property: boardTree.groupByProperty!.name})}
>
<FormattedMessage
id='BoardComponent.no-property'
defaultMessage='No {property}'
values={{
property: boardTree.groupByProperty?.name,
property: boardTree.groupByProperty!.name,
}}
/>
</div>
@ -343,19 +348,19 @@ class BoardComponent extends React.Component<Props, State> {
}}
onDragOver={(e) => {
ref.current.classList.add('dragover')
ref.current!.classList.add('dragover')
e.preventDefault()
}}
onDragEnter={(e) => {
ref.current.classList.add('dragover')
ref.current!.classList.add('dragover')
e.preventDefault()
}}
onDragLeave={(e) => {
ref.current.classList.remove('dragover')
ref.current!.classList.remove('dragover')
e.preventDefault()
}}
onDrop={(e) => {
ref.current.classList.remove('dragover')
ref.current!.classList.remove('dragover')
e.preventDefault()
this.onDropToColumn(group.option)
}}
@ -384,7 +389,7 @@ class BoardComponent extends React.Component<Props, State> {
id='delete'
icon={<DeleteIcon/>}
name={intl.formatMessage({id: 'BoardComponent.delete', defaultMessage: 'Delete'})}
onClick={() => mutator.deletePropertyOption(boardTree, boardTree.groupByProperty, group.option)}
onClick={() => mutator.deletePropertyOption(boardTree, boardTree.groupByProperty!, group.option)}
/>
<Menu.Separator/>
{Constants.menuColors.map((color) => (
@ -392,7 +397,7 @@ class BoardComponent extends React.Component<Props, State> {
key={color.id}
id={color.id}
name={color.name}
onClick={() => mutator.changePropertyOptionColor(boardTree.board, boardTree.groupByProperty, group.option, color.id)}
onClick={() => mutator.changePropertyOptionColor(boardTree.board, boardTree.groupByProperty!, group.option, color.id)}
/>
))}
</Menu>
@ -419,25 +424,25 @@ class BoardComponent extends React.Component<Props, State> {
if (this.draggedCards?.length < 1) {
return
}
ref.current.classList.add('dragover')
ref.current!.classList.add('dragover')
e.preventDefault()
}}
onDragEnter={(e) => {
if (this.draggedCards?.length < 1) {
return
}
ref.current.classList.add('dragover')
ref.current!.classList.add('dragover')
e.preventDefault()
}}
onDragLeave={(e) => {
if (this.draggedCards?.length < 1) {
return
}
ref.current.classList.remove('dragover')
ref.current!.classList.remove('dragover')
e.preventDefault()
}}
onDrop={(e) => {
(e.target as HTMLElement).classList.remove('dragover')
ref.current!.classList.remove('dragover')
e.preventDefault()
if (this.draggedCards?.length < 1) {
return
@ -473,32 +478,77 @@ class BoardComponent extends React.Component<Props, State> {
}
}
private async addCard(groupByOptionId?: string): Promise<void> {
private addCardFromTemplate = async (cardTemplateId?: string) => {
this.addCard(undefined, cardTemplateId)
}
private async addCard(groupByOptionId?: string, cardTemplateId?: string): Promise<void> {
const {boardTree} = this.props
const {activeView, board} = boardTree
const card = new MutableCard()
let card: MutableCard
let blocksToInsert: IBlock[]
if (cardTemplateId) {
const templateCardTree = new MutableCardTree(cardTemplateId)
await templateCardTree.sync()
const newCardTree = templateCardTree.templateCopy()
card = newCardTree.card
card.isTemplate = false
card.title = ''
blocksToInsert = [newCardTree.card, ...newCardTree.contents]
} else {
card = new MutableCard()
blocksToInsert = [card]
}
card.parentId = boardTree.board.id
card.properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
card.icon = BlockIcons.shared.randomIcon()
const propertiesThatMeetFilters = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
if (boardTree.groupByProperty) {
if (groupByOptionId) {
card.properties[boardTree.groupByProperty.id] = groupByOptionId
propertiesThatMeetFilters[boardTree.groupByProperty.id] = groupByOptionId
} else {
delete card.properties[boardTree.groupByProperty.id]
delete propertiesThatMeetFilters[boardTree.groupByProperty.id]
}
}
await mutator.insertBlock(card, 'add card', async () => {
this.setState({shownCard: card})
}, async () => {
this.setState({shownCard: undefined})
})
card.properties = {...card.properties, ...propertiesThatMeetFilters}
card.icon = BlockIcons.shared.randomIcon()
await mutator.insertBlocks(
blocksToInsert,
'add card',
async () => {
this.setState({shownCardId: card.id})
},
async () => {
this.setState({shownCardId: undefined})
},
)
}
private addCardTemplate = async () => {
const {boardTree} = this.props
const cardTemplate = new MutableCard()
cardTemplate.isTemplate = true
cardTemplate.parentId = boardTree.board.id
await mutator.insertBlock(
cardTemplate,
'add card template',
async () => {
this.setState({shownCardId: cardTemplate.id})
}, async () => {
this.setState({shownCardId: undefined})
},
)
}
private editCardTemplate = (cardTemplateId: string) => {
this.setState({shownCardId: cardTemplateId})
}
private async propertyNameChanged(option: IPropertyOption, text: string): Promise<void> {
const {boardTree} = this.props
await mutator.changePropertyOptionValue(boardTree, boardTree.groupByProperty, option, text)
await mutator.changePropertyOptionValue(boardTree, boardTree.groupByProperty!, option, text)
}
private cardClicked(e: React.MouseEvent, card: Card): void {
@ -528,7 +578,7 @@ class BoardComponent extends React.Component<Props, State> {
this.setState({selectedCardIds})
}
} else {
this.setState({selectedCardIds: [], shownCard: card})
this.setState({selectedCardIds: [], shownCardId: card.id})
}
e.stopPropagation()
@ -545,8 +595,7 @@ class BoardComponent extends React.Component<Props, State> {
color: 'propColorDefault',
}
Utils.assert(boardTree.groupByProperty)
await mutator.insertPropertyOption(boardTree, boardTree.groupByProperty, option, 'add group')
await mutator.insertPropertyOption(boardTree, boardTree.groupByProperty!, option, 'add group')
}
private async onDropToColumn(option: IPropertyOption) {
@ -554,23 +603,23 @@ class BoardComponent extends React.Component<Props, State> {
const {draggedCards, draggedHeaderOption} = this
const optionId = option ? option.id : undefined
Utils.assertValue(mutator)
Utils.assertValue(boardTree)
if (draggedCards.length > 0) {
await mutator.performAsUndoGroup(async () => {
const description = draggedCards.length > 1 ? `drag ${draggedCards.length} cards` : 'drag card'
const awaits = []
for (const draggedCard of draggedCards) {
Utils.log(`ondrop. Card: ${draggedCard.title}, column: ${optionId}`)
const oldValue = draggedCard.properties[boardTree.groupByProperty.id]
const oldValue = draggedCard.properties[boardTree.groupByProperty!.id]
if (optionId !== oldValue) {
await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty.id, optionId, description)
awaits.push(mutator.changePropertyValue(draggedCard, boardTree.groupByProperty!.id, optionId, description))
}
}
await Promise.all(awaits)
})
} else if (draggedHeaderOption) {
Utils.log(`ondrop. Header option: ${draggedHeaderOption.value}, column: ${option?.value}`)
Utils.assertValue(boardTree.groupByProperty)
// Move option to new index
const visibleOptionIds = boardTree.visibleGroups.map((o) => o.option.id)
@ -590,7 +639,7 @@ class BoardComponent extends React.Component<Props, State> {
const {boardTree} = this.props
const {activeView} = boardTree
const {draggedCards} = this
const optionId = card.properties[activeView.groupById]
const optionId = card.properties[activeView.groupById!]
if (draggedCards.length < 1 || draggedCards.includes(card)) {
return
@ -605,7 +654,7 @@ class BoardComponent extends React.Component<Props, State> {
const isDraggingDown = cardOrder.indexOf(firstDraggedCard.id) <= cardOrder.indexOf(card.id)
cardOrder = cardOrder.filter((id) => !draggedCardIds.includes(id))
let destIndex = cardOrder.indexOf(card.id)
if (firstDraggedCard.properties[boardTree.groupByProperty.id] === optionId && isDraggingDown) {
if (firstDraggedCard.properties[boardTree.groupByProperty!.id] === optionId && isDraggingDown) {
// If the cards are in the same column and dragging down, drop after the target card
destIndex += 1
}
@ -613,14 +662,15 @@ class BoardComponent extends React.Component<Props, State> {
await mutator.performAsUndoGroup(async () => {
// Update properties of dragged cards
const awaits = []
for (const draggedCard of draggedCards) {
Utils.log(`draggedCard: ${draggedCard.title}, column: ${optionId}`)
const oldOptionId = draggedCard.properties[boardTree.groupByProperty.id]
const oldOptionId = draggedCard.properties[boardTree.groupByProperty!.id]
if (optionId !== oldOptionId) {
await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty.id, optionId, description)
awaits.push(mutator.changePropertyValue(draggedCard, boardTree.groupByProperty!.id, optionId, description))
}
}
await Promise.all(awaits)
await mutator.changeViewCardOrder(activeView, cardOrder, description)
})
}
@ -634,7 +684,11 @@ class BoardComponent extends React.Component<Props, State> {
mutator.performAsUndoGroup(async () => {
for (const cardId of selectedCardIds) {
const card = this.props.boardTree.allCards.find((o) => o.id === cardId)
mutator.deleteBlock(card, selectedCardIds.length > 1 ? `delete ${selectedCardIds.length} cards` : 'delete card')
if (card) {
mutator.deleteBlock(card, selectedCardIds.length > 1 ? `delete ${selectedCardIds.length} cards` : 'delete card')
} else {
Utils.assertFailure(`Selected card not found: ${cardId}`)
}
}
})

View File

@ -24,7 +24,7 @@
display: flex;
flex-direction: column;
width: 100%;
.MenuWrapper: {
.MenuWrapper {
position: relative;
}
}

View File

@ -7,9 +7,8 @@ import {BlockIcons} from '../blockIcons'
import {MutableTextBlock} from '../blocks/textBlock'
import {BoardTree} from '../viewModel/boardTree'
import {PropertyType} from '../blocks/board'
import {CardTree, MutableCardTree} from '../viewModel/cardTree'
import {CardTree} from '../viewModel/cardTree'
import mutator from '../mutator'
import {OctoListener} from '../octoListener'
import {Utils} from '../utils'
import MenuWrapper from '../widgets/menuWrapper'
@ -29,56 +28,34 @@ import './cardDetail.scss'
type Props = {
boardTree: BoardTree
cardId: string
cardTree: CardTree
intl: IntlShape
}
type State = {
cardTree?: CardTree
title: string
}
class CardDetail extends React.Component<Props, State> {
private titleRef = React.createRef<Editable>()
private cardListener?: OctoListener
shouldComponentUpdate() {
shouldComponentUpdate(): boolean {
return true
}
componentDidMount(): void {
this.titleRef.current?.focus()
}
constructor(props: Props) {
super(props)
this.state = {
title: '',
title: props.cardTree.card.title,
}
}
componentDidMount() {
this.cardListener = new OctoListener()
this.cardListener.open([this.props.cardId], async (blockId) => {
Utils.log(`cardListener.onChanged: ${blockId}`)
await cardTree.sync()
this.setState({cardTree})
})
const cardTree = new MutableCardTree(this.props.cardId)
cardTree.sync().then(() => {
this.setState({cardTree, title: cardTree.card.title})
setTimeout(() => {
if (this.titleRef.current) {
this.titleRef.current.focus()
}
}, 0)
})
}
componentWillUnmount() {
this.cardListener?.close()
this.cardListener = undefined
}
render() {
const {boardTree, intl} = this.props
const {cardTree} = this.state
const {boardTree, cardTree, intl} = this.props
const {board} = boardTree
if (!cardTree) {
return null
@ -94,7 +71,7 @@ class CardDetail extends React.Component<Props, State> {
key={block.id}
block={block}
cardId={card.id}
cardTree={cardTree}
contents={cardTree.contents}
/>
))}
</div>)
@ -110,7 +87,7 @@ class CardDetail extends React.Component<Props, State> {
const block = new MutableTextBlock()
block.parentId = card.id
block.title = text
block.order = cardTree.contents.length * 1000
block.order = (this.props.cardTree.contents.length + 1) * 1000
mutator.insertBlock(block, 'add card text')
}
}}
@ -150,8 +127,13 @@ class CardDetail extends React.Component<Props, State> {
value={this.state.title}
placeholderText='Untitled'
onChange={(title: string) => this.setState({title})}
onSave={() => mutator.changeTitle(card, this.state.title)}
onCancel={() => this.setState({title: this.state.cardTree.card.title})}
saveOnEsc={true}
onSave={() => {
if (this.state.title !== this.props.cardTree.card.title) {
mutator.changeTitle(card, this.state.title)
}
}}
onCancel={() => this.setState({title: this.props.cardTree.card.title})}
/>
{/* Property list */}
@ -231,7 +213,7 @@ class CardDetail extends React.Component<Props, State> {
onClick={() => {
const block = new MutableTextBlock()
block.parentId = card.id
block.order = cardTree.contents.length * 1000
block.order = (this.props.cardTree.contents.length + 1) * 1000
mutator.insertBlock(block, 'add text')
}}
/>
@ -239,7 +221,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, cardTree.contents.length * 1000),
(file) => mutator.createImageBlock(card.id, file, (this.props.cardTree.contents.length + 1) * 1000),
'.jpg,.jpeg,.png',
)}
/>

View File

@ -1,24 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {Card} from '../blocks/card'
import {BoardTree} from '../viewModel/boardTree'
import mutator from '../mutator'
import Menu from '../widgets/menu'
import {OctoListener} from '../octoListener'
import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree'
import {CardTree, MutableCardTree} from '../viewModel/cardTree'
import DeleteIcon from '../widgets/icons/delete'
import Menu from '../widgets/menu'
import CardDetail from './cardDetail'
import Dialog from './dialog'
type Props = {
boardTree: BoardTree
card: Card
cardId: string
onClose: () => void
showCard: (cardId?: string) => void
}
class CardDialog extends React.Component<Props> {
render() {
type State = {
cardTree?: CardTree
}
class CardDialog extends React.Component<Props, State> {
state: State = {}
private cardListener?: OctoListener
shouldComponentUpdate(): boolean {
return true
}
componentDidMount(): void {
this.createCardTreeAndSync()
}
private async createCardTreeAndSync() {
const cardTree = new MutableCardTree(this.props.cardId)
await cardTree.sync()
this.createListener()
this.setState({cardTree})
Utils.log(`cardDialog.createCardTreeAndSync: ${cardTree.card.id}`)
}
private createListener() {
this.cardListener = new OctoListener()
this.cardListener.open(
[this.props.cardId],
async (blocks) => {
Utils.log(`cardListener.onChanged: ${blocks.length}`)
const newCardTree = this.state.cardTree!.mutableCopy()
if (newCardTree.incrementalUpdate(blocks)) {
this.setState({cardTree: newCardTree})
}
},
async () => {
Utils.log('cardListener.onReconnect')
const newCardTree = this.state.cardTree!.mutableCopy()
await newCardTree.sync()
this.setState({cardTree: newCardTree})
},
)
}
componentWillUnmount(): void {
this.cardListener?.close()
this.cardListener = undefined
}
render(): JSX.Element {
const {cardTree} = this.state
const menu = (
<Menu position='left'>
<Menu.Text
@ -26,10 +81,22 @@ class CardDialog extends React.Component<Props> {
icon={<DeleteIcon/>}
name='Delete'
onClick={async () => {
await mutator.deleteBlock(this.props.card, 'delete card')
const card = this.state.cardTree?.card
if (!card) {
Utils.assertFailure()
return
}
await mutator.deleteBlock(card, 'delete card')
this.props.onClose()
}}
/>
{(cardTree && !cardTree.card.isTemplate) &&
<Menu.Text
id='makeTemplate'
name='New template from card'
onClick={this.makeTemplate}
/>
}
</Menu>
)
return (
@ -37,13 +104,49 @@ class CardDialog extends React.Component<Props> {
onClose={this.props.onClose}
toolsMenu={menu}
>
<CardDetail
boardTree={this.props.boardTree}
cardId={this.props.card.id}
/>
{(cardTree?.card.isTemplate) &&
<div className='banner'>
<FormattedMessage
id='CardDialog.editing-template'
defaultMessage="You're editing a template"
/>
</div>
}
{this.state.cardTree &&
<CardDetail
boardTree={this.props.boardTree}
cardTree={this.state.cardTree}
/>
}
</Dialog>
)
}
private makeTemplate = async () => {
const {cardTree} = this.state
if (!cardTree) {
Utils.assertFailure('this.state.cardTree')
return
}
const newCardTree = cardTree.templateCopy()
newCardTree.card.isTemplate = true
newCardTree.card.title = 'New Template'
Utils.log(`Created new template: ${newCardTree.card.id}`)
const blocksToInsert = [newCardTree.card, ...newCardTree.contents]
await mutator.insertBlocks(
blocksToInsert,
'create template from card',
async () => {
this.props.showCard(newCardTree.card.id)
},
async () => {
this.props.showCard(undefined)
},
)
}
}
export {CardDialog}

View File

@ -1,19 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {FC} from 'react'
import {IntlShape, injectIntl} from 'react-intl'
import {injectIntl, IntlShape} from 'react-intl'
import mutator from '../mutator'
import {IBlock} from '../blocks/block'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import mutator from '../mutator'
import {Utils} from '../utils'
import IconButton from '../widgets/buttons/iconButton'
import DeleteIcon from '../widgets/icons/delete'
import OptionsIcon from '../widgets/icons/options'
import IconButton from '../widgets/buttons/iconButton'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import './comment.scss'
import {Utils} from '../utils'
type Props = {
comment: IBlock

View File

@ -1,17 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {injectIntl, IntlShape, FormattedMessage} from 'react-intl'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {MutableCommentBlock} from '../blocks/commentBlock'
import {IBlock} from '../blocks/block'
import {Utils} from '../utils'
import {MutableCommentBlock} from '../blocks/commentBlock'
import mutator from '../mutator'
import {Utils} from '../utils'
import Button from '../widgets/buttons/button'
import Comment from './comment'
import './commentsList.scss'
import {MarkdownEditor} from './markdownEditor'
@ -79,7 +77,7 @@ class CommentsList extends React.Component<Props, State> {
text={this.state.newComment}
placeholderText={intl.formatMessage({id: 'CardDetail.new-comment-placeholder', defaultMessage: 'Add a comment...'})}
onChange={(value: string) => {
if (this.state.newComment != value) {
if (this.state.newComment !== value) {
this.setState({newComment: value})
}
}}

View File

@ -3,47 +3,43 @@
import React from 'react'
import {IOrderedBlock} from '../blocks/orderedBlock'
import {CardTree} from '../viewModel/cardTree'
import {OctoUtils} from '../octoUtils'
import mutator from '../mutator'
import {Utils} from '../utils'
import {MutableTextBlock} from '../blocks/textBlock'
import {MutableDividerBlock} from '../blocks/dividerBlock'
import {IOrderedBlock} from '../blocks/orderedBlock'
import {MutableTextBlock} from '../blocks/textBlock'
import mutator from '../mutator'
import {OctoUtils} from '../octoUtils'
import {Utils} from '../utils'
import IconButton from '../widgets/buttons/iconButton'
import AddIcon from '../widgets/icons/add'
import DeleteIcon from '../widgets/icons/delete'
import DividerIcon from '../widgets/icons/divider'
import ImageIcon from '../widgets/icons/image'
import OptionsIcon from '../widgets/icons/options'
import SortDownIcon from '../widgets/icons/sortDown'
import SortUpIcon from '../widgets/icons/sortUp'
import TextIcon from '../widgets/icons/text'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import OptionsIcon from '../widgets/icons/options'
import SortUpIcon from '../widgets/icons/sortUp'
import SortDownIcon from '../widgets/icons/sortDown'
import DeleteIcon from '../widgets/icons/delete'
import AddIcon from '../widgets/icons/add'
import TextIcon from '../widgets/icons/text'
import ImageIcon from '../widgets/icons/image'
import DividerIcon from '../widgets/icons/divider'
import IconButton from '../widgets/buttons/iconButton'
import {MarkdownEditor} from './markdownEditor'
import './contentBlock.scss'
import {MarkdownEditor} from './markdownEditor'
type Props = {
block: IOrderedBlock
cardId: string
cardTree: CardTree
contents: readonly IOrderedBlock[]
}
class ContentBlock extends React.Component<Props> {
shouldComponentUpdate(): boolean {
return true
}
class ContentBlock extends React.PureComponent<Props> {
public render(): JSX.Element | null {
const {cardId, contents, block} = this.props
public render(): JSX.Element {
const {cardId, cardTree, block} = this.props
if (block.type !== 'text' && block.type !== 'image' && block.type !== 'divider') {
Utils.assertFailure(`Block type is unknown: ${block.type}`)
return null
}
const index = cardTree.contents.indexOf(block)
const index = contents.indexOf(block)
return (
<div className='ContentBlock octo-block'>
<div className='octo-block-margin'>
@ -56,20 +52,20 @@ class ContentBlock extends React.Component<Props> {
name='Move up'
icon={<SortUpIcon/>}
onClick={() => {
const previousBlock = cardTree.contents[index - 1]
const newOrder = OctoUtils.getOrderBefore(previousBlock, cardTree.contents)
const previousBlock = contents[index - 1]
const newOrder = OctoUtils.getOrderBefore(previousBlock, contents)
Utils.log(`moveUp ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move up')
}}
/>}
{index < (cardTree.contents.length - 1) &&
{index < (contents.length - 1) &&
<Menu.Text
id='moveDown'
name='Move down'
icon={<SortDownIcon/>}
onClick={() => {
const nextBlock = cardTree.contents[index + 1]
const newOrder = OctoUtils.getOrderAfter(nextBlock, cardTree.contents)
const nextBlock = contents[index + 1]
const newOrder = OctoUtils.getOrderAfter(nextBlock, contents)
Utils.log(`moveDown ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move down')
}}
@ -88,7 +84,7 @@ class ContentBlock extends React.Component<Props> {
newBlock.parentId = cardId
// TODO: Handle need to reorder all blocks
newBlock.order = OctoUtils.getOrderBefore(block, cardTree.contents)
newBlock.order = OctoUtils.getOrderBefore(block, contents)
Utils.log(`insert block ${block.id}, order: ${block.order}`)
mutator.insertBlock(newBlock, 'insert card text')
}}
@ -100,7 +96,7 @@ class ContentBlock extends React.Component<Props> {
onClick={() => {
Utils.selectLocalFile(
(file) => {
mutator.createImageBlock(cardId, file, OctoUtils.getOrderBefore(block, cardTree.contents))
mutator.createImageBlock(cardId, file, OctoUtils.getOrderBefore(block, contents))
},
'.jpg,.jpeg,.png')
}}
@ -114,7 +110,7 @@ class ContentBlock extends React.Component<Props> {
newBlock.parentId = cardId
// TODO: Handle need to reorder all blocks
newBlock.order = OctoUtils.getOrderBefore(block, cardTree.contents)
newBlock.order = OctoUtils.getOrderBefore(block, contents)
Utils.log(`insert block ${block.id}, order: ${block.order}`)
mutator.insertBlock(newBlock, 'insert card text')
}}

View File

@ -24,6 +24,11 @@
overflow-x: hidden;
overflow-y: auto;
> .banner {
background-color: rgba(230, 220, 192, 0.9);
text-align: center;
padding: 10px;
}
> .toolbar {
display: flex;
flex-direction: row;

View File

@ -19,10 +19,7 @@ type Props = {
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void
}
type State = {
}
class Editable extends React.Component<Props, State> {
class Editable extends React.PureComponent<Props> {
static defaultProps = {
text: '',
isMarkdown: false,
@ -30,46 +27,46 @@ class Editable extends React.Component<Props, State> {
allowEmpty: true,
}
private _text = ''
private privateText = ''
get text(): string {
return this._text
return this.privateText
}
set text(value: string) {
const {isMarkdown} = this.props
if (!value) {
this.elementRef.current.innerText = ''
if (value) {
this.elementRef.current!.innerHTML = isMarkdown ? Utils.htmlFromMarkdown(value) : Utils.htmlEncode(value)
} else {
this.elementRef.current.innerHTML = isMarkdown ? Utils.htmlFromMarkdown(value) : Utils.htmlEncode(value)
this.elementRef.current!.innerText = ''
}
this._text = value || ''
this.privateText = value || ''
}
private elementRef = React.createRef<HTMLDivElement>()
constructor(props: Props) {
super(props)
this._text = props.text || ''
this.privateText = props.text || ''
}
componentDidUpdate() {
this._text = this.props.text || ''
componentDidUpdate(): void {
this.privateText = this.props.text || ''
}
focus() {
this.elementRef.current.focus()
focus(): void {
this.elementRef.current!.focus()
// Put cursor at end
document.execCommand('selectAll', false, null)
document.getSelection().collapseToEnd()
document.execCommand('selectAll', false, undefined)
document.getSelection()?.collapseToEnd()
}
blur() {
this.elementRef.current.blur()
blur(): void {
this.elementRef.current!.blur()
}
render() {
render(): JSX.Element {
const {text, className, style, placeholderText, isMarkdown, isMultiline, onFocus, onBlur, onKeyDown, onChanged} = this.props
const initialStyle = {...this.props.style}
@ -81,8 +78,8 @@ class Editable extends React.Component<Props, State> {
html = ''
}
const element =
(<div
const element = (
<div
ref={this.elementRef}
className={'octo-editable ' + className}
contentEditable={true}
@ -93,9 +90,9 @@ class Editable extends React.Component<Props, State> {
dangerouslySetInnerHTML={{__html: html}}
onFocus={() => {
this.elementRef.current.innerText = this.text
this.elementRef.current.style.color = style?.color || null
this.elementRef.current.classList.add('active')
this.elementRef.current!.innerText = this.text
this.elementRef.current!.style!.color = style?.color || ''
this.elementRef.current!.classList.add('active')
if (onFocus) {
onFocus()
@ -103,7 +100,7 @@ class Editable extends React.Component<Props, State> {
}}
onBlur={async () => {
const newText = this.elementRef.current.innerText
const newText = this.elementRef.current!.innerText
const oldText = this.props.text || ''
if (this.props.allowEmpty || newText) {
if (newText !== oldText && onChanged) {
@ -115,7 +112,7 @@ class Editable extends React.Component<Props, State> {
this.text = oldText // Reset text
}
this.elementRef.current.classList.remove('active')
this.elementRef.current!.classList.remove('active')
if (onBlur) {
onBlur()
}
@ -124,17 +121,17 @@ class Editable extends React.Component<Props, State> {
onKeyDown={(e) => {
if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC
e.stopPropagation()
this.elementRef.current.blur()
this.elementRef.current!.blur()
} else if (!isMultiline && e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Return
e.stopPropagation()
this.elementRef.current.blur()
this.elementRef.current!.blur()
}
if (onKeyDown) {
onKeyDown(e)
}
}}
/>);
/>)
return element
}

View File

@ -85,69 +85,70 @@ class FilterComponent extends React.Component<Props> {
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'
key={key}
>
<MenuWrapper>
<Button>{propertyName}</Button>
<Menu>
{board.cardProperties.filter((o) => o.type === 'select').map((o) => (
return (
<div
className='octo-filterclause'
key={key}
>
<MenuWrapper>
<Button>{propertyName}</Button>
<Menu>
{board.cardProperties.filter((o) => o.type === 'select').map((o) => (
<Menu.Text
key={o.id}
id={o.id}
name={o.name}
onClick={(optionId: string) => {
const filterIndex = activeView.filter.filters.indexOf(filter)
Utils.assert(filterIndex >= 0, "Can't find filter")
const filterGroup = new FilterGroup(activeView.filter)
const newFilter = filterGroup.filters[filterIndex] as FilterClause
Utils.assert(newFilter, `No filter at index ${filterIndex}`)
if (newFilter.propertyId !== optionId) {
newFilter.propertyId = optionId
newFilter.values = []
mutator.changeViewFilter(activeView, filterGroup)
}
}}
/>))}
</Menu>
</MenuWrapper>
<MenuWrapper>
<Button>{FilterClause.filterConditionDisplayString(filter.condition, intl)}</Button>
<Menu>
<Menu.Text
key={o.id}
id={o.id}
name={o.name}
onClick={(optionId: string) => {
const filterIndex = activeView.filter.filters.indexOf(filter)
Utils.assert(filterIndex >= 0, "Can't find filter")
const filterGroup = new FilterGroup(activeView.filter)
const newFilter = filterGroup.filters[filterIndex] as FilterClause
Utils.assert(newFilter, `No filter at index ${filterIndex}`)
if (newFilter.propertyId !== optionId) {
newFilter.propertyId = optionId
newFilter.values = []
mutator.changeViewFilter(activeView, filterGroup)
}
}}
/>))}
</Menu>
</MenuWrapper>
<MenuWrapper>
<Button>{FilterClause.filterConditionDisplayString(filter.condition, intl)}</Button>
<Menu>
<Menu.Text
id='includes'
name={intl.formatMessage({id: 'Filter.includes', defaultMessage: 'includes'})}
onClick={(id) => this.conditionClicked(id, filter)}
id='includes'
name={intl.formatMessage({id: 'Filter.includes', defaultMessage: 'includes'})}
onClick={(id) => this.conditionClicked(id, filter)}
/>
<Menu.Text
id='notIncludes'
name={intl.formatMessage({id: 'Filter.not-includes', defaultMessage: 'doesn\'t include'})}
onClick={(id) => this.conditionClicked(id, filter)}
/>
<Menu.Text
id='isEmpty'
name={intl.formatMessage({id: 'Filter.is-empty', defaultMessage: 'is empty'})}
onClick={(id) => this.conditionClicked(id, filter)}
/>
<Menu.Text
id='isNotEmpty'
name={intl.formatMessage({id: 'Filter.is-not-empty', defaultMessage: 'is not empty'})}
onClick={(id) => this.conditionClicked(id, filter)}
/>
</Menu>
</MenuWrapper>
{
template && this.filterValue(filter, template)
}
<div className='octo-spacer'/>
<Button onClick={() => this.deleteClicked(filter)}>
<FormattedMessage
id='FilterComponent.delete'
defaultMessage='Delete'
/>
<Menu.Text
id='notIncludes'
name={intl.formatMessage({id: 'Filter.not-includes', defaultMessage: 'doesn\'t include'})}
onClick={(id) => this.conditionClicked(id, filter)}
/>
<Menu.Text
id='isEmpty'
name={intl.formatMessage({id: 'Filter.is-empty', defaultMessage: 'is empty'})}
onClick={(id) => this.conditionClicked(id, filter)}
/>
<Menu.Text
id='isNotEmpty'
name={intl.formatMessage({id: 'Filter.is-not-empty', defaultMessage: 'is not empty'})}
onClick={(id) => this.conditionClicked(id, filter)}
/>
</Menu>
</MenuWrapper>
{
this.filterValue(filter, template)
}
<div className='octo-spacer'/>
<Button onClick={() => this.deleteClicked(filter)}>
<FormattedMessage
id='FilterComponent.delete'
defaultMessage='Delete'
/>
</Button>
</div>)
</Button>
</div>)
})}
<br/>
@ -162,7 +163,7 @@ class FilterComponent extends React.Component<Props> {
)
}
private filterValue(filter: FilterClause, template: IPropertyTemplate): JSX.Element {
private filterValue(filter: FilterClause, template: IPropertyTemplate): JSX.Element | undefined {
const {boardTree} = this.props
const {activeView: view} = boardTree
@ -177,10 +178,6 @@ class FilterComponent extends React.Component<Props> {
displayValue = '(empty)'
}
if (!template) {
return null
}
return (
<MenuWrapper>
<Button>{displayValue}</Button>

View File

@ -26,7 +26,7 @@ type State = {
}
export class FlashMessages extends React.PureComponent<Props, State> {
private timeout: ReturnType<typeof setTimeout> = null
private timeout?: ReturnType<typeof setTimeout>
constructor(props: Props) {
super(props)
@ -35,7 +35,7 @@ export class FlashMessages extends React.PureComponent<Props, State> {
emitter.on('message', (message: FlashMessage) => {
if (this.timeout) {
clearTimeout(this.timeout)
this.timeout = null
this.timeout = undefined
}
this.timeout = setTimeout(this.handleFadeOut, this.props.milliseconds - 200)
this.setState({message})
@ -48,16 +48,18 @@ export class FlashMessages extends React.PureComponent<Props, State> {
}
handleTimeout = (): void => {
this.setState({message: null, fadeOut: false})
this.setState({message: undefined, fadeOut: false})
}
handleClick = (): void => {
clearTimeout(this.timeout)
this.timeout = null
if (this.timeout) {
clearTimeout(this.timeout)
this.timeout = undefined
}
this.handleFadeOut()
}
public render(): JSX.Element {
public render(): JSX.Element | null {
if (!this.state.message) {
return null
}

View File

@ -10,13 +10,16 @@ type Props = {
}
type State = {
isDragging?: boolean
startX?: number
offset?: number
isDragging: boolean
startX: number
offset: number
}
class HorizontalGrip extends React.PureComponent<Props, State> {
state: State = {
isDragging: false,
startX: 0,
offset: 0,
}
render(): JSX.Element {

View File

@ -4,9 +4,8 @@ import EasyMDE from 'easymde'
import React from 'react'
import SimpleMDE from 'react-simplemde-editor'
import './markdownEditor.scss'
import {Utils} from '../utils'
import './markdownEditor.scss'
type Props = {
text?: string
@ -29,13 +28,13 @@ class MarkdownEditor extends React.Component<Props, State> {
}
get text(): string {
return this.elementRef.current.state.value
return this.elementRef.current!.state.value
}
set text(value: string) {
this.elementRef.current.setState({value})
this.elementRef.current!.setState({value})
}
private editorInstance: EasyMDE
private editorInstance?: EasyMDE
private frameRef = React.createRef<HTMLDivElement>()
private elementRef = React.createRef<SimpleMDE>()
private previewRef = React.createRef<HTMLDivElement>()
@ -45,14 +44,18 @@ class MarkdownEditor extends React.Component<Props, State> {
this.state = {isEditing: false}
}
componentDidUpdate(prevProps: Props, prevState: State) {
shouldComponentUpdate(): boolean {
return true
}
componentDidUpdate(): void {
const newText = this.props.text || ''
if (!this.state.isEditing && this.text !== newText) {
this.text = newText
}
}
showEditor() {
showEditor(): void {
const cm = this.editorInstance?.codemirror
if (cm) {
setTimeout(() => {
@ -66,12 +69,12 @@ class MarkdownEditor extends React.Component<Props, State> {
this.setState({isEditing: true})
}
hideEditor() {
hideEditor(): void {
this.editorInstance?.codemirror?.getInputField()?.blur()
this.setState({isEditing: false})
}
render() {
render(): JSX.Element {
const {text, placeholderText, uniqueId, onFocus, onBlur, onChange} = this.props
let html: string
@ -81,21 +84,21 @@ class MarkdownEditor extends React.Component<Props, State> {
html = Utils.htmlFromMarkdown(placeholderText || '')
}
const previewElement =
(<div
ref={this.previewRef}
className={text ? 'octo-editor-preview' : 'octo-editor-preview octo-placeholder'}
style={{display: this.state.isEditing ? 'none' : null}}
dangerouslySetInnerHTML={{__html: html}}
onClick={() => {
if (!this.state.isEditing) {
this.showEditor()
}
}}
/>);
const previewElement = (
<div
ref={this.previewRef}
className={text ? 'octo-editor-preview' : 'octo-editor-preview octo-placeholder'}
style={{display: this.state.isEditing ? 'none' : undefined}}
dangerouslySetInnerHTML={{__html: html}}
onClick={() => {
if (!this.state.isEditing) {
this.showEditor()
}
}}
/>)
const editorElement =
(<div
const editorElement = (
<div
className='octo-editor-activeEditor'
// Use visibility instead of display here so the editor is pre-rendered, avoiding a flash on showEditor
@ -125,22 +128,22 @@ class MarkdownEditor extends React.Component<Props, State> {
events={{
change: () => {
if (this.state.isEditing) {
const newText = this.elementRef.current.state.value
const newText = this.elementRef.current!.state.value
onChange?.(newText)
}
},
blur: () => {
const newText = this.elementRef.current.state.value
const newText = this.elementRef.current!.state.value
const oldText = this.props.text || ''
if (newText !== oldText && onChange) {
const newHtml = newText ? Utils.htmlFromMarkdown(newText) : Utils.htmlFromMarkdown(placeholderText || '')
this.previewRef.current.innerHTML = newHtml
this.previewRef.current!.innerHTML = newHtml
onChange(newText)
}
this.text = newText
this.frameRef.current.classList.remove('active')
this.frameRef.current!.classList.remove('active')
if (onBlur) {
onBlur(newText)
@ -149,9 +152,9 @@ class MarkdownEditor extends React.Component<Props, State> {
this.hideEditor()
},
focus: () => {
this.frameRef.current.classList.add('active')
this.frameRef.current!.classList.add('active')
this.elementRef.current.setState({value: this.text})
this.elementRef.current!.setState({value: this.text})
if (onFocus) {
onFocus()
@ -176,8 +179,8 @@ class MarkdownEditor extends React.Component<Props, State> {
/>
</div>)
const element =
(<div
const element = (
<div
ref={this.frameRef}
className={`MarkdownEditor octo-editor ${this.props.className || ''}`}
>

View File

@ -3,13 +3,12 @@
import React from 'react'
import {IPropertyOption, IPropertyTemplate} from '../blocks/board'
import {Card} from '../blocks/card'
import {IPropertyTemplate, IPropertyOption} from '../blocks/board'
import {OctoUtils} from '../octoUtils'
import mutator from '../mutator'
import {OctoUtils} from '../octoUtils'
import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree'
import Editable from '../widgets/editable'
import ValueSelector from '../widgets/valueSelector'
@ -56,7 +55,7 @@ export default class PropertyValueElement extends React.Component<Props, State>
className += ' empty'
}
if (readOnly) {
if (readOnly || !boardTree) {
return (
<div
className={`${className} ${propertyColorCssClassName}`}
@ -87,7 +86,7 @@ export default class PropertyValueElement extends React.Component<Props, State>
value,
color: 'propColorDefault',
}
await mutator.insertPropertyOption(this.props.boardTree, propertyTemplate, option, 'add property option')
await mutator.insertPropertyOption(boardTree, propertyTemplate, option, 'add property option')
mutator.changePropertyValue(card, propertyTemplate.id, option.id)
}
}

View File

@ -1,9 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types'
import React from 'react'
import ReactDOM from 'react-dom'
import PropTypes from 'prop-types'
type Props = {
children: React.ReactNode
@ -21,21 +21,21 @@ export default class RootPortal extends React.PureComponent<Props> {
this.el = document.createElement('div')
}
componentDidMount() {
componentDidMount(): void {
const rootPortal = document.getElementById('root-portal')
if (rootPortal) {
rootPortal.appendChild(this.el)
}
}
componentWillUnmount() {
componentWillUnmount(): void {
const rootPortal = document.getElementById('root-portal')
if (rootPortal) {
rootPortal.removeChild(this.el)
}
}
render() {
render(): JSX.Element {
return ReactDOM.createPortal(
this.props.children,
this.el,

View File

@ -7,7 +7,8 @@
min-height: 100%;
color: rgb(var(--sidebar-fg));
background-color: rgb(var(--sidebar-bg));
padding: 10px 0;
padding: 10px 0;
overflow-y: scroll;
&.hidden {
position: absolute;
@ -25,6 +26,10 @@
}
}
>* {
flex-shrink: 0;
}
.octo-sidebar-header {
display: flex;
flex-direction: row;
@ -94,11 +99,11 @@
}
&.expanded {
.SubmenuTriangleIcon {
.DisclosureTriangleIcon {
transform: rotate(90deg);
}
}
.SubmenuTriangleIcon {
.DisclosureTriangleIcon {
transition: 200ms ease-in-out;
transform: rotate(0deg);
}
@ -107,10 +112,14 @@
.octo-sidebar-title {
cursor: pointer;
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.OptionsIcon, .SubmenuTriangleIcon, .DotIcon {
.OptionsIcon, .DisclosureTriangleIcon, .DotIcon {
fill: rgba(var(--sidebar-fg), 0.5);
flex-shrink: 0;
}
.HideSidebarIcon {

View File

@ -1,34 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {injectIntl, IntlShape, FormattedMessage} from 'react-intl'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {Archiver} from '../archiver'
import {mattermostTheme, darkTheme, lightTheme, setTheme} from '../theme'
import {Board, MutableBoard} from '../blocks/board'
import {BoardTree} from '../viewModel/boardTree'
import {BoardView, MutableBoardView} from '../blocks/boardView'
import mutator from '../mutator'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import {darkTheme, lightTheme, mattermostTheme, setTheme} from '../theme'
import {WorkspaceTree} from '../viewModel/workspaceTree'
import Button from '../widgets/buttons/button'
import IconButton from '../widgets/buttons/iconButton'
import DeleteIcon from '../widgets/icons/delete'
import DotIcon from '../widgets/icons/dot'
import DuplicateIcon from '../widgets/icons/duplicate'
import HamburgerIcon from '../widgets/icons/hamburger'
import HideSidebarIcon from '../widgets/icons/hideSidebar'
import OptionsIcon from '../widgets/icons/options'
import ShowSidebarIcon from '../widgets/icons/showSidebar'
import HideSidebarIcon from '../widgets/icons/hideSidebar'
import HamburgerIcon from '../widgets/icons/hamburger'
import DeleteIcon from '../widgets/icons/delete'
import SubmenuTriangleIcon from '../widgets/icons/submenuTriangle'
import DotIcon from '../widgets/icons/dot'
import IconButton from '../widgets/buttons/iconButton'
import Button from '../widgets/buttons/button'
import {WorkspaceTree} from '../viewModel/workspaceTree'
import {BoardView} from '../blocks/boardView'
import DisclosureTriangle from '../widgets/icons/disclosureTriangle'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import './sidebar.scss'
type Props = {
showBoard: (id: string) => void
showView: (id: string, boardId?: string) => void
workspaceTree: WorkspaceTree,
boardTree?: BoardTree,
activeBoardId?: string
setLanguage: (lang: string) => void,
intl: IntlShape
}
@ -90,18 +89,13 @@ class Sidebar extends React.Component<Props, State> {
</div>
{
boards.map((board) => {
const displayTitle = board.title || (
<FormattedMessage
id='Sidebar.untitled-board'
defaultMessage='(Untitled Board)'
/>
)
const displayTitle: string = board.title || intl.formatMessage({id: 'Sidebar.untitled-board', defaultMessage: '(Untitled Board)'})
const boardViews = views.filter((view) => view.parentId === board.id)
return (
<div key={board.id}>
<div className={'octo-sidebar-item ' + (collapsedBoards[board.id] ? 'collapsed' : 'expanded')}>
<IconButton
icon={<SubmenuTriangleIcon/>}
icon={<DisclosureTriangle/>}
onClick={() => {
const newCollapsedBoards = {...this.state.collapsedBoards}
newCollapsedBoards[board.id] = !newCollapsedBoards[board.id]
@ -113,18 +107,19 @@ class Sidebar extends React.Component<Props, State> {
onClick={() => {
this.boardClicked(board)
}}
title={displayTitle}
>
{board.icon ? `${board.icon} ${displayTitle}` : displayTitle}
</div>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu position='left'>
<Menu.Text
id='delete'
id='deleteBoard'
name={intl.formatMessage({id: 'Sidebar.delete-board', defaultMessage: 'Delete Board'})}
icon={<DeleteIcon/>}
onClick={async () => {
const nextBoardId = boards.length > 1 ? boards.find((o) => o.id !== board.id).id : undefined
const nextBoardId = boards.length > 1 ? boards.find((o) => o.id !== board.id)?.id : undefined
mutator.deleteBlock(
board,
'delete block',
@ -137,6 +132,24 @@ class Sidebar extends React.Component<Props, State> {
)
}}
/>
<Menu.Text
id='duplicateBoard'
name={intl.formatMessage({id: 'Sidebar.duplicate-board', defaultMessage: 'Duplicate Board'})}
icon={<DuplicateIcon/>}
onClick={async () => {
await mutator.duplicateBoard(
board.id,
'duplicate board',
async (newBoardId) => {
newBoardId && this.props.showBoard(newBoardId)
},
async () => {
this.props.showBoard(board.id)
},
)
}}
/>
</Menu>
</MenuWrapper>
</div>
@ -158,13 +171,9 @@ class Sidebar extends React.Component<Props, State> {
onClick={() => {
this.viewClicked(board, view)
}}
title={view.title || intl.formatMessage({id: 'Sidebar.untitled-view', defaultMessage: '(Untitled View)'})}
>
{view.title || (
<FormattedMessage
id='Sidebar.untitled-view'
defaultMessage='(Untitled View)'
/>
)}
{view.title || intl.formatMessage({id: 'Sidebar.untitled-view', defaultMessage: '(Untitled View)'})}
</div>
</div>
))}
@ -256,12 +265,17 @@ class Sidebar extends React.Component<Props, State> {
}
private addBoardClicked = async () => {
const {boardTree, showBoard} = this.props
const {showBoard, intl} = this.props
const oldBoardId = boardTree?.board?.id
const oldBoardId = this.props.activeBoardId
const board = new MutableBoard()
await mutator.insertBlock(
board,
const view = new MutableBoardView()
view.viewType = 'board'
view.parentId = board.id
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board View'})
await mutator.insertBlocks(
[board, view],
'add board',
async () => {
showBoard(board.id)

View File

@ -3,44 +3,43 @@
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {Constants} from '../constants'
import {BlockIcons} from '../blockIcons'
import {IBlock} from '../blocks/block'
import {IPropertyTemplate} from '../blocks/board'
import {Card, MutableCard} from '../blocks/card'
import {BoardTree} from '../viewModel/boardTree'
import {MutableBoardView} from '../blocks/boardView'
import {MutableCard} from '../blocks/card'
import {Constants} from '../constants'
import mutator from '../mutator'
import {Utils} from '../utils'
import MenuWrapper from '../widgets/menuWrapper'
import {BoardTree} from '../viewModel/boardTree'
import {MutableCardTree} from '../viewModel/cardTree'
import SortDownIcon from '../widgets/icons/sortDown'
import SortUpIcon from '../widgets/icons/sortUp'
import MenuWrapper from '../widgets/menuWrapper'
import {CardDialog} from './cardDialog'
import {HorizontalGrip} from './horizontalGrip'
import RootPortal from './rootPortal'
import './tableComponent.scss'
import TableHeaderMenu from './tableHeaderMenu'
import {TableRow} from './tableRow'
import ViewHeader from './viewHeader'
import ViewTitle from './viewTitle'
import TableHeaderMenu from './tableHeaderMenu'
import './tableComponent.scss'
import {HorizontalGrip} from './horizontalGrip'
import {MutableBoardView} from '../blocks/boardView'
type Props = {
boardTree?: BoardTree
boardTree: BoardTree
showView: (id: string) => void
setSearchText: (text: string) => void
setSearchText: (text?: string) => void
}
type State = {
shownCard?: Card
shownCardId?: string
}
class TableComponent extends React.Component<Props, State> {
private draggedHeaderTemplate: IPropertyTemplate
private draggedHeaderTemplate?: IPropertyTemplate
private cardIdToRowMap = new Map<string, React.RefObject<TableRow>>()
private cardIdToFocusOnRender: string
private cardIdToFocusOnRender?: string
state: State = {}
shouldComponentUpdate(): boolean {
@ -49,18 +48,6 @@ class TableComponent extends React.Component<Props, State> {
render(): JSX.Element {
const {boardTree, showView} = this.props
if (!boardTree || !boardTree.board) {
return (
<div>
<FormattedMessage
id='TableComponent.loading'
defaultMessage='Loading...'
/>
</div>
)
}
const {board, cards, activeView} = boardTree
const titleRef = React.createRef<HTMLDivElement>()
@ -74,12 +61,14 @@ class TableComponent extends React.Component<Props, State> {
return (
<div className='TableComponent octo-app'>
{this.state.shownCard &&
{this.state.shownCardId &&
<RootPortal>
<CardDialog
key={this.state.shownCardId}
boardTree={boardTree}
card={this.state.shownCard}
onClose={() => this.setState({shownCard: undefined})}
cardId={this.state.shownCardId}
onClose={() => this.setState({shownCardId: undefined})}
showCard={(cardId) => this.setState({shownCardId: cardId})}
/>
</RootPortal>}
<div className='octo-frame'>
@ -93,7 +82,10 @@ class TableComponent extends React.Component<Props, State> {
boardTree={boardTree}
showView={showView}
setSearchText={this.props.setSearchText}
addCard={this.addCard}
addCard={this.addCardAndShow}
addCardFromTemplate={this.addCardFromTemplate}
addCardTemplate={this.addCardTemplate}
editCardTemplate={this.editCardTemplate}
/>
{/* Main content */}
@ -135,13 +127,13 @@ class TableComponent extends React.Component<Props, State> {
onDrag={(offset) => {
const originalWidth = this.columnWidth(Constants.titleColumnId)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
titleRef.current.style.width = `${newWidth}px`
titleRef.current!.style!.width = `${newWidth}px`
}}
onDragEnd={(offset) => {
Utils.log(`onDragEnd offset: ${offset}`)
const originalWidth = this.columnWidth(Constants.titleColumnId)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
titleRef.current.style.width = `${newWidth}px`
titleRef.current!.style!.width = `${newWidth}px`
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[Constants.titleColumnId]) {
@ -167,78 +159,83 @@ class TableComponent extends React.Component<Props, State> {
sortIcon = sortOption.reversed ? <SortUpIcon/> : <SortDownIcon/>
}
return (<div
key={template.id}
ref={headerRef}
style={{overflow: 'unset', width: this.columnWidth(template.id)}}
className='octo-table-cell header-cell'
return (
<div
key={template.id}
ref={headerRef}
style={{overflow: 'unset', width: this.columnWidth(template.id)}}
className='octo-table-cell header-cell'
onDragOver={(e) => {
e.preventDefault(); (e.target as HTMLElement).classList.add('dragover')
}}
onDragEnter={(e) => {
e.preventDefault(); (e.target as HTMLElement).classList.add('dragover')
}}
onDragLeave={(e) => {
e.preventDefault(); (e.target as HTMLElement).classList.remove('dragover')
}}
onDrop={(e) => {
e.preventDefault(); (e.target as HTMLElement).classList.remove('dragover'); this.onDropToColumn(template)
}}
>
<MenuWrapper>
<div
className='octo-label'
style={{cursor: 'pointer'}}
draggable={true}
onDragStart={() => {
this.draggedHeaderTemplate = template
onDragOver={(e) => {
e.preventDefault();
(e.target as HTMLElement).classList.add('dragover')
}}
onDragEnter={(e) => {
e.preventDefault();
(e.target as HTMLElement).classList.add('dragover')
}}
onDragLeave={(e) => {
e.preventDefault();
(e.target as HTMLElement).classList.remove('dragover')
}}
onDrop={(e) => {
e.preventDefault();
(e.target as HTMLElement).classList.remove('dragover')
this.onDropToColumn(template)
}}
>
<MenuWrapper>
<div
className='octo-label'
style={{cursor: 'pointer'}}
draggable={true}
onDragStart={() => {
this.draggedHeaderTemplate = template
}}
onDragEnd={() => {
this.draggedHeaderTemplate = undefined
}}
>
{template.name}
{sortIcon}
</div>
<TableHeaderMenu
boardTree={boardTree}
templateId={template.id}
/>
</MenuWrapper>
<div className='octo-spacer'/>
<HorizontalGrip
onDrag={(offset) => {
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
headerRef.current!.style.width = `${newWidth}px`
}}
onDragEnd={() => {
this.draggedHeaderTemplate = undefined
onDragEnd={(offset) => {
Utils.log(`onDragEnd offset: ${offset}`)
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
headerRef.current!.style.width = `${newWidth}px`
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[template.id]) {
columnWidths[template.id] = newWidth
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
}
}}
>
{template.name}
{sortIcon}
</div>
<TableHeaderMenu
boardTree={boardTree}
templateId={template.id}
/>
</MenuWrapper>
<div className='octo-spacer'/>
<HorizontalGrip
onDrag={(offset) => {
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
headerRef.current.style.width = `${newWidth}px`
}}
onDragEnd={(offset) => {
Utils.log(`onDragEnd offset: ${offset}`)
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
headerRef.current.style.width = `${newWidth}px`
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[template.id]) {
columnWidths[template.id] = newWidth
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
}
}}
/>
</div>)
</div>)
})}
</div>
{/* Rows, one per card */}
{cards.map((card) => {
const openButonRef = React.createRef<HTMLDivElement>()
const tableRowRef = React.createRef<TableRow>()
let focusOnMount = false
@ -247,20 +244,22 @@ class TableComponent extends React.Component<Props, State> {
focusOnMount = true
}
const tableRow = (<TableRow
key={card.id}
ref={tableRowRef}
boardTree={boardTree}
card={card}
focusOnMount={focusOnMount}
onSaveWithEnter={() => {
console.log('WORKING')
if (cards.length > 0 && cards[cards.length - 1] === card) {
this.addCard(false)
}
console.log('STILL WORKING')
}}
/>)
const tableRow = (
<TableRow
key={card.id}
ref={tableRowRef}
boardTree={boardTree}
card={card}
focusOnMount={focusOnMount}
onSaveWithEnter={() => {
if (cards.length > 0 && cards[cards.length - 1] === card) {
this.addCard(false)
}
}}
showCard={(cardId) => {
this.setState({shownCardId: cardId})
}}
/>)
this.cardIdToRowMap.set(card.id, tableRowRef)
@ -290,21 +289,43 @@ class TableComponent extends React.Component<Props, State> {
}
private columnWidth(templateId: string): number {
return Math.max(Constants.minColumnWidth, this.props.boardTree?.activeView?.columnWidths[templateId] || 0)
return Math.max(Constants.minColumnWidth, this.props.boardTree.activeView.columnWidths[templateId] || 0)
}
private addCard = async (show = false) => {
private addCardAndShow = () => {
this.addCard(true)
}
private addCardFromTemplate = async (cardTemplateId?: string) => {
this.addCard(true, cardTemplateId)
}
private addCard = async (show = false, cardTemplateId?: string) => {
const {boardTree} = this.props
const card = new MutableCard()
let card: MutableCard
let blocksToInsert: IBlock[]
if (cardTemplateId) {
const templateCardTree = new MutableCardTree(cardTemplateId)
await templateCardTree.sync()
const newCardTree = templateCardTree.templateCopy()
card = newCardTree.card
card.isTemplate = false
card.title = ''
blocksToInsert = [newCardTree.card, ...newCardTree.contents]
} else {
card = new MutableCard()
blocksToInsert = [card]
}
card.parentId = boardTree.board.id
card.icon = BlockIcons.shared.randomIcon()
await mutator.insertBlock(
card,
await mutator.insertBlocks(
blocksToInsert,
'add card',
async () => {
if (show) {
this.setState({shownCard: card})
this.setState({shownCardId: card.id})
} else {
// Focus on this card's title inline on next render
this.cardIdToFocusOnRender = card.id
@ -313,6 +334,27 @@ class TableComponent extends React.Component<Props, State> {
)
}
private addCardTemplate = async () => {
const {boardTree} = this.props
const cardTemplate = new MutableCard()
cardTemplate.isTemplate = true
cardTemplate.parentId = boardTree.board.id
await mutator.insertBlock(
cardTemplate,
'add card template',
async () => {
this.setState({shownCardId: cardTemplate.id})
}, async () => {
this.setState({shownCardId: undefined})
},
)
}
private editCardTemplate = (cardTemplateId: string) => {
this.setState({shownCardId: cardTemplateId})
}
private async onDropToColumn(template: IPropertyTemplate) {
const {draggedHeaderTemplate} = this
if (!draggedHeaderTemplate) {

View File

@ -5,7 +5,6 @@ import React, {FC} from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {Constants} from '../constants'
import mutator from '../mutator'
import {BoardTree} from '../viewModel/boardTree'
import Menu from '../widgets/menu'

View File

@ -3,18 +3,14 @@
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {BoardTree} from '../viewModel/boardTree'
import {Card} from '../blocks/card'
import mutator from '../mutator'
import {Constants} from '../constants'
import Editable from '../widgets/editable'
import mutator from '../mutator'
import {BoardTree} from '../viewModel/boardTree'
import Button from '../widgets/buttons/button'
import Editable from '../widgets/editable'
import PropertyValueElement from './propertyValueElement'
import {CardDialog} from './cardDialog'
import RootPortal from './rootPortal'
import './tableRow.scss'
type Props = {
@ -22,10 +18,10 @@ type Props = {
card: Card
focusOnMount: boolean
onSaveWithEnter: () => void
showCard: (cardId: string) => void
}
type State = {
showCard: boolean
title: string
}
@ -34,7 +30,6 @@ class TableRow extends React.Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
showCard: false,
title: props.card.title,
}
}
@ -45,7 +40,7 @@ class TableRow extends React.Component<Props, State> {
componentDidMount(): void {
if (this.props.focusOnMount) {
setTimeout(() => this.titleRef.current.focus(), 10)
setTimeout(() => this.titleRef.current!.focus(), 10)
}
}
@ -84,21 +79,13 @@ class TableRow extends React.Component<Props, State> {
</div>
<div className='open-button'>
<Button onClick={() => this.setState({showCard: true})}>
<Button onClick={() => this.props.showCard(this.props.card.id)}>
<FormattedMessage
id='TableRow.open'
defaultMessage='Open'
/>
</Button>
</div>
{this.state.showCard &&
<RootPortal>
<CardDialog
boardTree={boardTree}
card={card}
onClose={() => this.setState({showCard: false})}
/>
</RootPortal>}
</div>
{/* Columns, one per property */}
@ -126,7 +113,7 @@ class TableRow extends React.Component<Props, State> {
}
private columnWidth(templateId: string): number {
return Math.max(Constants.minColumnWidth, this.props.boardTree?.activeView?.columnWidths[templateId] || 0)
return Math.max(Constants.minColumnWidth, this.props.boardTree.activeView.columnWidths[templateId] || 0)
}
focusOnTitle(): void {

View File

@ -1,43 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {injectIntl, IntlShape, FormattedMessage} from 'react-intl'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {Archiver} from '../archiver'
import {ISortOption, MutableBoardView} from '../blocks/boardView'
import {BlockIcons} from '../blockIcons'
import {MutableCard} from '../blocks/card'
import {IPropertyTemplate} from '../blocks/board'
import {BoardTree} from '../viewModel/boardTree'
import ViewMenu from '../components/viewMenu'
import {CsvExporter} from '../csvExporter'
import {ISortOption, MutableBoardView} from '../blocks/boardView'
import {MutableCard} from '../blocks/card'
import {CardFilter} from '../cardFilter'
import ViewMenu from '../components/viewMenu'
import {Constants} from '../constants'
import {CsvExporter} from '../csvExporter'
import mutator from '../mutator'
import {Utils} from '../utils'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import CheckIcon from '../widgets/icons/check'
import DropdownIcon from '../widgets/icons/dropdown'
import OptionsIcon from '../widgets/icons/options'
import SortUpIcon from '../widgets/icons/sortUp'
import SortDownIcon from '../widgets/icons/sortDown'
import {BoardTree} from '../viewModel/boardTree'
import Button from '../widgets/buttons/button'
import ButtonWithMenu from '../widgets/buttons/buttonWithMenu'
import IconButton from '../widgets/buttons/iconButton'
import Button from '../widgets/buttons/button'
import CheckIcon from '../widgets/icons/check'
import DeleteIcon from '../widgets/icons/delete'
import DropdownIcon from '../widgets/icons/dropdown'
import OptionsIcon from '../widgets/icons/options'
import SortDownIcon from '../widgets/icons/sortDown'
import SortUpIcon from '../widgets/icons/sortUp'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import {Editable} from './editable'
import FilterComponent from './filterComponent'
import './viewHeader.scss'
import {sendFlashMessage} from './flashMessages'
import {Constants} from '../constants'
type Props = {
boardTree?: BoardTree
boardTree: BoardTree
showView: (id: string) => void
setSearchText: (text: string) => void
addCard: (show: boolean) => void
setSearchText: (text?: string) => void
addCard: () => void
addCardFromTemplate: (cardTemplateId?: string) => void
addCardTemplate: () => void
editCardTemplate: (cardTemplateId: string) => void
withGroupBy?: boolean
intl: IntlShape
}
@ -56,12 +56,12 @@ class ViewHeader extends React.Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {isSearching: Boolean(this.props.boardTree?.getSearchText()), showFilter: false}
this.state = {isSearching: Boolean(this.props.boardTree.getSearchText()), showFilter: false}
}
componentDidUpdate(prevPros: Props, prevState: State): void {
if (this.state.isSearching && !prevState.isSearching) {
this.searchFieldRef.current.focus()
this.searchFieldRef.current!.focus()
}
}
@ -75,7 +75,7 @@ class ViewHeader extends React.Component<Props, State> {
private onSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.keyCode === 27) { // ESC: Clear search
this.searchFieldRef.current.text = ''
this.searchFieldRef.current!.text = ''
this.setState({isSearching: false})
this.props.setSearchText(undefined)
e.preventDefault()
@ -90,10 +90,10 @@ class ViewHeader extends React.Component<Props, State> {
const {boardTree} = this.props
const {board, activeView} = boardTree
const startCount = boardTree?.cards?.length
const startCount = boardTree.cards.length
let optionIndex = 0
await mutator.performAsUndoGroup(async () => {
mutator.performAsUndoGroup(async () => {
for (let i = 0; i < count; i++) {
const card = new MutableCard()
card.parentId = boardTree.board.id
@ -107,7 +107,26 @@ class ViewHeader extends React.Component<Props, State> {
optionIndex = (optionIndex + 1) % boardTree.groupByProperty.options.length
card.properties[boardTree.groupByProperty.id] = option.id
}
await mutator.insertBlock(card, 'test add card')
mutator.insertBlock(card, 'test add card')
}
})
}
private async testDistributeCards() {
const {boardTree} = this.props
mutator.performAsUndoGroup(async () => {
let optionIndex = 0
for (const card of boardTree.cards) {
if (boardTree.groupByProperty && boardTree.groupByProperty.options.length > 0) {
// Cycle through options
const option = boardTree.groupByProperty.options[optionIndex]
optionIndex = (optionIndex + 1) % boardTree.groupByProperty.options.length
const newCard = new MutableCard(card)
if (newCard.properties[boardTree.groupByProperty.id] !== option.id) {
newCard.properties[boardTree.groupByProperty.id] = option.id
mutator.updateBlock(newCard, card, 'test distribute cards')
}
}
}
})
}
@ -115,9 +134,9 @@ class ViewHeader extends React.Component<Props, State> {
private async testRandomizeIcons() {
const {boardTree} = this.props
await mutator.performAsUndoGroup(async () => {
mutator.performAsUndoGroup(async () => {
for (const card of boardTree.cards) {
await mutator.changeIcon(card, BlockIcons.shared.randomIcon(), 'randomize icon')
mutator.changeIcon(card, BlockIcons.shared.randomIcon(), 'randomize icon')
}
})
}
@ -163,10 +182,6 @@ class ViewHeader extends React.Component<Props, State> {
name={option.name}
isOn={activeView.visiblePropertyIds.includes(option.id)}
onClick={(propertyId: string) => {
const property = boardTree.board.cardProperties.find((o: IPropertyTemplate) => o.id === propertyId)
Utils.assertValue(property)
Utils.log(`Toggle property ${property.name}`)
let newVisiblePropertyIds = []
if (activeView.visiblePropertyIds.includes(propertyId)) {
newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o: string) => o !== propertyId)
@ -266,28 +281,37 @@ class ViewHeader extends React.Component<Props, State> {
</>
}
{this.sortDisplayOptions().map((option) => (
<Menu.Text
key={option.id}
id={option.id}
name={option.name}
rightIcon={(activeView.sortOptions[0]?.propertyId === option.id) ? activeView.sortOptions[0].reversed ? <SortUpIcon/> : <SortDownIcon/> : undefined}
onClick={(propertyId: string) => {
let newSortOptions: ISortOption[] = []
if (activeView.sortOptions[0] && activeView.sortOptions[0].propertyId === propertyId) {
{this.sortDisplayOptions().map((option) => {
let rightIcon: JSX.Element | undefined
if (activeView.sortOptions.length > 0) {
const sortOption = activeView.sortOptions[0]
if (sortOption.propertyId === option.id) {
rightIcon = sortOption.reversed ? <SortUpIcon/> : <SortDownIcon/>
}
}
return (
<Menu.Text
key={option.id}
id={option.id}
name={option.name}
rightIcon={rightIcon}
onClick={(propertyId: string) => {
let newSortOptions: ISortOption[] = []
if (activeView.sortOptions[0] && activeView.sortOptions[0].propertyId === propertyId) {
// Already sorting by name, so reverse it
newSortOptions = [
{propertyId, reversed: !activeView.sortOptions[0].reversed},
]
} else {
newSortOptions = [
{propertyId, reversed: false},
]
}
mutator.changeViewSortOptions(activeView, newSortOptions)
}}
/>
))}
newSortOptions = [
{propertyId, reversed: !activeView.sortOptions[0].reversed},
]
} else {
newSortOptions = [
{propertyId, reversed: false},
]
}
mutator.changeViewSortOptions(activeView, newSortOptions)
}}
/>
)
})}
</Menu>
</MenuWrapper>
{this.state.isSearching &&
@ -323,6 +347,9 @@ class ViewHeader extends React.Component<Props, State> {
name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export Board Archive'})}
onClick={() => Archiver.exportBoardTree(boardTree)}
/>
<Menu.Separator/>
<Menu.Text
id='testAdd100Cards'
name={intl.formatMessage({id: 'ViewHeader.test-add-100-cards', defaultMessage: 'TEST: Add 100 cards'})}
@ -333,6 +360,11 @@ class ViewHeader extends React.Component<Props, State> {
name={intl.formatMessage({id: 'ViewHeader.test-add-1000-cards', defaultMessage: 'TEST: Add 1,000 cards'})}
onClick={() => this.testAddCards(1000)}
/>
<Menu.Text
id='testDistributeCards'
name={intl.formatMessage({id: 'ViewHeader.test-distribute-cards', defaultMessage: 'TEST: Distribute cards'})}
onClick={() => this.testDistributeCards()}
/>
<Menu.Text
id='testRandomizeIcons'
name={intl.formatMessage({id: 'ViewHeader.test-randomize-icons', defaultMessage: 'TEST: Randomize icons'})}
@ -340,9 +372,10 @@ class ViewHeader extends React.Component<Props, State> {
/>
</Menu>
</MenuWrapper>
<ButtonWithMenu
onClick={() => {
this.props.addCard(true)
this.props.addCard()
}}
text={(
<FormattedMessage
@ -360,10 +393,56 @@ class ViewHeader extends React.Component<Props, State> {
/>
</b>
</Menu.Label>
<Menu.Separator/>
{boardTree.cardTemplates.map((cardTemplate) => {
return (
<Menu.Text
key={cardTemplate.id}
id={cardTemplate.id}
name={cardTemplate.title || intl.formatMessage({id: 'ViewHeader.untitled', defaultMessage: 'Untitled'})}
onClick={() => {
this.props.addCardFromTemplate(cardTemplate.id)
}}
rightIcon={
<MenuWrapper stopPropagationOnToggle={true}>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='left'>
<Menu.Text
id='edit'
name={intl.formatMessage({id: 'ViewHeader.edit-template', defaultMessage: 'Edit'})}
onClick={() => {
this.props.editCardTemplate(cardTemplate.id)
}}
/>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'ViewHeader.delete-template', defaultMessage: 'Delete'})}
onClick={async () => {
await mutator.deleteBlock(cardTemplate, 'delete card template')
}}
/>
</Menu>
</MenuWrapper>
}
/>
)
})}
<Menu.Text
id='example-template'
name={intl.formatMessage({id: 'ViewHeader.sample-templte', defaultMessage: 'Sample template'})}
onClick={() => sendFlashMessage({content: 'Not implemented yet', severity: 'low'})}
id='empty-template'
name={intl.formatMessage({id: 'ViewHeader.empty-card', defaultMessage: 'Empty card'})}
onClick={() => {
this.props.addCard()
}}
/>
<Menu.Text
id='add-template'
name={intl.formatMessage({id: 'ViewHeader.add-template', defaultMessage: '+ New template'})}
onClick={() => this.props.addCardTemplate()}
/>
</Menu>
</ButtonWithMenu>

View File

@ -1,43 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {Board} from '../blocks/board'
import {MutableBoardView} from '../blocks/boardView'
import {BoardTree} from '../viewModel/boardTree'
import {Constants} from '../constants'
import mutator from '../mutator'
import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree'
import Menu from '../widgets/menu'
import {Constants} from '../constants'
type Props = {
boardTree?: BoardTree
boardTree: BoardTree
board: Board,
showView: (id: string) => void
intl: IntlShape
}
export default class ViewMenu extends React.Component<Props> {
handleDeleteView = async () => {
export class ViewMenu extends React.PureComponent<Props> {
private handleDeleteView = async () => {
const {boardTree, showView} = this.props
Utils.log('deleteView')
const view = boardTree.activeView
const nextView = boardTree.views.find((o) => o !== view)
await mutator.deleteBlock(view, 'delete view')
showView(nextView.id)
if (nextView) {
showView(nextView.id)
}
}
handleViewClick = (id: string) => {
private handleViewClick = (id: string) => {
const {boardTree, showView} = this.props
Utils.log('view ' + id)
const view = boardTree.views.find((o) => o.id === id)
showView(view.id)
Utils.assert(view, `view not found: ${id}`)
if (view) {
showView(view.id)
}
}
handleAddViewBoard = async () => {
const {board, boardTree, showView} = this.props
private handleAddViewBoard = async () => {
const {board, boardTree, showView, intl} = this.props
Utils.log('addview-board')
const view = new MutableBoardView()
view.title = 'Board View'
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board View'})
view.viewType = 'board'
view.parentId = board.id
@ -54,12 +61,12 @@ export default class ViewMenu extends React.Component<Props> {
})
}
handleAddViewTable = async () => {
const {board, boardTree, showView} = this.props
private handleAddViewTable = async () => {
const {board, boardTree, showView, intl} = this.props
Utils.log('addview-table')
const view = new MutableBoardView()
view.title = 'Table View'
view.title = intl.formatMessage({id: 'View.NewTableTitle', defaultMessage: 'Table View'})
view.viewType = 'table'
view.parentId = board.id
view.visiblePropertyIds = board.cardProperties.map((o) => o.id)
@ -79,7 +86,7 @@ export default class ViewMenu extends React.Component<Props> {
})
}
render() {
render(): JSX.Element {
const {boardTree} = this.props
return (
<Menu>
@ -91,11 +98,12 @@ export default class ViewMenu extends React.Component<Props> {
onClick={this.handleViewClick}
/>))}
<Menu.Separator/>
{boardTree.views.length > 1 && <Menu.Text
id='__deleteView'
name='Delete View'
onClick={this.handleDeleteView}
/>}
{boardTree.views.length > 1 &&
<Menu.Text
id='__deleteView'
name='Delete View'
onClick={this.handleDeleteView}
/>}
<Menu.SubMenu
id='__addView'
name='Add View'
@ -115,3 +123,5 @@ export default class ViewMenu extends React.Component<Props> {
)
}
}
export default injectIntl(ViewMenu)

View File

@ -26,5 +26,6 @@
.Editable {
margin-bottom: 0px;
flex-grow: 1;
}
}

View File

@ -1,17 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {injectIntl, IntlShape, FormattedMessage} from 'react-intl'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {BlockIcons} from '../blockIcons'
import {Board} from '../blocks/board'
import mutator from '../mutator'
import Editable from '../widgets/editable'
import Button from '../widgets/buttons/button'
import Editable from '../widgets/editable'
import EmojiIcon from '../widgets/icons/emoji'
import BlockIconSelector from './blockIconSelector'
import './viewTitle.scss'
type Props = {
@ -62,6 +61,7 @@ class ViewTitle extends React.Component<Props, State> {
value={this.state.title}
placeholderText={intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled Board'})}
onChange={(title) => this.setState({title})}
saveOnEsc={true}
onSave={() => mutator.changeTitle(board, this.state.title)}
onCancel={() => this.setState({title: this.props.board.title})}
/>

View File

@ -2,14 +2,13 @@
// See LICENSE.txt for license information.
import React from 'react'
import {BoardTree} from '../viewModel/boardTree'
import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree'
import {WorkspaceTree} from '../viewModel/workspaceTree'
import BoardComponent from './boardComponent'
import Sidebar from './sidebar'
import {TableComponent} from './tableComponent'
import './workspaceComponent.scss'
type Props = {
@ -17,22 +16,22 @@ type Props = {
boardTree?: BoardTree
showBoard: (id: string) => void
showView: (id: string, boardId?: string) => void
setSearchText: (text: string) => void
setSearchText: (text?: string) => void
setLanguage: (lang: string) => void
}
class WorkspaceComponent extends React.Component<Props> {
render() {
class WorkspaceComponent extends React.PureComponent<Props> {
render(): JSX.Element {
const {boardTree, workspaceTree, showBoard, showView, setLanguage} = this.props
Utils.assert(workspaceTree)
const element =
(<div className='WorkspaceComponent'>
const element = (
<div className='WorkspaceComponent'>
<Sidebar
showBoard={showBoard}
showView={showView}
workspaceTree={workspaceTree}
boardTree={boardTree}
activeBoardId={boardTree?.board.id}
setLanguage={setLanguage}
/>
{this.mainComponent()}
@ -45,25 +44,27 @@ class WorkspaceComponent extends React.Component<Props> {
const {boardTree, setSearchText, showView} = this.props
const {activeView} = boardTree || {}
if (!activeView) {
if (!boardTree || !activeView) {
return <div/>
}
switch (activeView?.viewType) {
switch (activeView.viewType) {
case 'board': {
return (<BoardComponent
boardTree={boardTree}
setSearchText={setSearchText}
showView={showView}
/>)
return (
<BoardComponent
boardTree={boardTree}
setSearchText={setSearchText}
showView={showView}
/>)
}
case 'table': {
return (<TableComponent
boardTree={boardTree}
setSearchText={setSearchText}
showView={showView}
/>)
return (
<TableComponent
boardTree={boardTree}
setSearchText={setSearchText}
showView={showView}
/>)
}
default: {

View File

@ -10,7 +10,11 @@ class CsvExporter {
const {activeView} = boardTree
const viewToExport = view ?? activeView
const rows = CsvExporter.generateTableArray(boardTree, view)
if (!viewToExport) {
return
}
const rows = CsvExporter.generateTableArray(boardTree, viewToExport)
let csvContent = 'data:text/csv;charset=utf-8,'
@ -19,7 +23,7 @@ class CsvExporter {
csvContent += encodedRow + '\r\n'
})
const filename = `${Utils.sanitizeFilename(viewToExport.title)}.csv`
const filename = `${Utils.sanitizeFilename(viewToExport.title || 'Untitled')}.csv`
const encodedUri = encodeURI(csvContent)
const link = document.createElement('a')
link.style.display = 'none'
@ -32,9 +36,8 @@ class CsvExporter {
// TODO: Remove or reuse link
}
private static generateTableArray(boardTree: BoardTree, view?: BoardView): string[][] {
const {board, cards, activeView} = boardTree
const viewToExport = view ?? activeView
private static generateTableArray(boardTree: BoardTree, viewToExport: BoardView): string[][] {
const {board, cards} = boardTree
const rows: string[][] = []
const visibleProperties = board.cardProperties.filter((template) => viewToExport.visiblePropertyIds.includes(template.id))
@ -54,7 +57,7 @@ class CsvExporter {
const propertyValue = card.properties[template.id]
const displayValue = OctoUtils.propertyDisplayValue(card, propertyValue, template) || ''
if (template.type === 'number') {
const numericValue = propertyValue ? Number(propertyValue).toString() : undefined
const numericValue = propertyValue ? Number(propertyValue).toString() : ''
row.push(numericValue)
} else {
// Export as string

View File

@ -11,6 +11,7 @@ import {FilterGroup} from './filterGroup'
import octoClient from './octoClient'
import undoManager from './undomanager'
import {Utils} from './utils'
import {OctoUtils} from './octoUtils'
//
// The Mutator is used to make all changes to server state
@ -19,10 +20,10 @@ import {Utils} from './utils'
class Mutator {
private undoGroupId?: string
private beginUndoGroup(): string {
private beginUndoGroup(): string | undefined {
if (this.undoGroupId) {
Utils.assertFailure('UndoManager does not support nested groups')
return
return undefined
}
this.undoGroupId = Utils.createGuid()
return this.undoGroupId
@ -43,7 +44,9 @@ class Mutator {
} catch (err) {
Utils.assertFailure(`ERROR: ${err?.toString?.()}`)
}
this.endUndoGroup(groupId)
if (groupId) {
this.endUndoGroup(groupId)
}
}
async updateBlock(newBlock: IBlock, oldBlock: IBlock, description: string): Promise<void> {
@ -95,9 +98,11 @@ class Mutator {
},
async () => {
await beforeUndo?.()
const awaits = []
for (const block of blocks) {
await octoClient.deleteBlock(block.id)
awaits.push(octoClient.deleteBlock(block.id))
}
await Promise.all(awaits)
},
description,
this.undoGroupId,
@ -105,9 +110,7 @@ class Mutator {
}
async deleteBlock(block: IBlock, description?: string, beforeRedo?: () => Promise<void>, afterUndo?: () => Promise<void>) {
if (!description) {
description = `delete ${block.type}`
}
const actualDescription = description || `delete ${block.type}`
await undoManager.perform(
async () => {
@ -118,7 +121,7 @@ class Mutator {
await octoClient.insertBlock(block)
await afterUndo?.()
},
description,
actualDescription,
this.undoGroupId,
)
}
@ -144,6 +147,10 @@ class Mutator {
newBlock = board
break
}
default: {
Utils.assertFailure(`changeIcon: Invalid block type: ${block.type}`)
return
}
}
await this.updateBlock(newBlock, block, description)
@ -159,24 +166,23 @@ class Mutator {
async insertPropertyTemplate(boardTree: BoardTree, index = -1, template?: IPropertyTemplate) {
const {board, activeView} = boardTree
if (index < 0) {
index = board.cardProperties.length
if (!activeView) {
Utils.assertFailure('insertPropertyTemplate: no activeView')
return
}
if (!template) {
template = {
id: Utils.createGuid(),
name: 'New Property',
type: 'text',
options: [],
}
const newTemplate = template || {
id: Utils.createGuid(),
name: 'New Property',
type: 'text',
options: [],
}
const oldBlocks: IBlock[] = [board]
const newBoard = new MutableBoard(board)
newBoard.cardProperties.splice(index, 0, template)
const startIndex = (index >= 0) ? index : board.cardProperties.length
newBoard.cardProperties.splice(startIndex, 0, newTemplate)
const changedBlocks: IBlock[] = [newBoard]
let description = 'add property'
@ -185,7 +191,7 @@ class Mutator {
oldBlocks.push(activeView)
const newActiveView = new MutableBoardView(activeView)
newActiveView.visiblePropertyIds.push(template.id)
newActiveView.visiblePropertyIds.push(newTemplate.id)
changedBlocks.push(newActiveView)
description = 'add column'
@ -196,6 +202,10 @@ class Mutator {
async duplicatePropertyTemplate(boardTree: BoardTree, propertyId: string) {
const {board, activeView} = boardTree
if (!activeView) {
Utils.assertFailure('duplicatePropertyTemplate: no activeView')
return
}
const oldBlocks: IBlock[] = [board]
@ -296,7 +306,7 @@ class Mutator {
Utils.assert(board.cardProperties.includes(template))
const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)!
newTemplate.options.push(option)
await this.updateBlock(newBoard, board, description)
@ -306,7 +316,7 @@ class Mutator {
const {board} = boardTree
const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)!
newTemplate.options = newTemplate.options.filter((o) => o.id !== option.id)
await this.updateBlock(newBoard, board, 'delete option')
@ -317,20 +327,20 @@ class Mutator {
Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`)
const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)!
newTemplate.options.splice(destIndex, 0, newTemplate.options.splice(srcIndex, 1)[0])
await this.updateBlock(newBoard, board, 'reorder options')
}
async changePropertyOptionValue(boardTree: BoardTree, propertyTemplate: IPropertyTemplate, option: IPropertyOption, value: string) {
const {board, cards} = boardTree
const {board} = boardTree
const oldBlocks: IBlock[] = [board]
const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)
const newOption = newTemplate.options.find((o) => o.id === option.id)
const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)!
const newOption = newTemplate.options.find((o) => o.id === option.id)!
newOption.value = value
const changedBlocks: IBlock[] = [newBoard]
@ -341,15 +351,19 @@ class Mutator {
async changePropertyOptionColor(board: Board, template: IPropertyTemplate, option: IPropertyOption, color: string) {
const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)
const newOption = newTemplate.options.find((o) => o.id === option.id)
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)!
const newOption = newTemplate.options.find((o) => o.id === option.id)!
newOption.color = color
await this.updateBlock(newBoard, board, 'change option color')
}
async changePropertyValue(card: Card, propertyId: string, value?: string, description = 'change property') {
const newCard = new MutableCard(card)
newCard.properties[propertyId] = value
if (value) {
newCard.properties[propertyId] = value
} else {
delete newCard.properties[propertyId]
}
await this.updateBlock(newCard, card, description)
}
@ -357,7 +371,7 @@ class Mutator {
const {board} = boardTree
const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)
const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)!
newTemplate.type = type
const oldBlocks: IBlock[] = [board]
@ -369,7 +383,11 @@ class Mutator {
if (oldValue) {
const newValue = propertyTemplate.options.find((o) => o.id === oldValue)?.value
const newCard = new MutableCard(card)
newCard.properties[propertyTemplate.id] = newValue
if (newValue) {
newCard.properties[propertyTemplate.id] = newValue
} else {
delete newCard.properties[propertyTemplate.id]
}
newBlocks.push(newCard)
oldBlocks.push(card)
}
@ -381,7 +399,11 @@ class Mutator {
if (oldValue) {
const newValue = propertyTemplate.options.find((o) => o.value === oldValue)?.id
const newCard = new MutableCard(card)
newCard.properties[propertyTemplate.id] = newValue
if (newValue) {
newCard.properties[propertyTemplate.id] = newValue
} else {
delete newCard.properties[propertyTemplate.id]
}
newBlocks.push(newCard)
oldBlocks.push(card)
}
@ -399,7 +421,7 @@ class Mutator {
await this.updateBlock(newView, view, 'sort')
}
async changeViewFilter(view: BoardView, filter?: FilterGroup): Promise<void> {
async changeViewFilter(view: BoardView, filter: FilterGroup): Promise<void> {
const newView = new MutableBoardView(view)
newView.filter = filter
await this.updateBlock(newView, view, 'filter')
@ -460,6 +482,46 @@ class Mutator {
await this.updateBlock(newView, view, description)
}
// Duplicate
async duplicateCard(cardId: string, description = 'duplicate card', afterRedo?: (newBoardId: string) => Promise<void>, beforeUndo?: () => Promise<void>): Promise<[IBlock[], string]> {
const blocks = await octoClient.getSubtree(cardId, 2)
const [newBlocks1, idMap] = OctoUtils.duplicateBlockTree(blocks, cardId)
const newBlocks = newBlocks1.filter((o) => o.type !== 'comment')
Utils.log(`duplicateCard: duplicating ${newBlocks.length} blocks`)
const newCardId = idMap[cardId]
const newCard = newBlocks.find((o) => o.id === newCardId)!
newCard.title = `Copy of ${newCard.title}`
await this.insertBlocks(
newBlocks,
description,
async () => {
await afterRedo?.(newCardId)
},
beforeUndo,
)
return [newBlocks, newCardId]
}
async duplicateBoard(boardId: string, description = 'duplicate board', afterRedo?: (newBoardId: string) => Promise<void>, beforeUndo?: () => Promise<void>): Promise<[IBlock[], string]> {
const blocks = await octoClient.getSubtree(boardId, 3)
const [newBlocks1, idMap] = OctoUtils.duplicateBlockTree(blocks, boardId)
const newBlocks = newBlocks1.filter((o) => o.type !== 'comment')
Utils.log(`duplicateBoard: duplicating ${newBlocks.length} blocks`)
const newBoardId = idMap[boardId]
const newBoard = newBlocks.find((o) => o.id === newBoardId)!
newBoard.title = `Copy of ${newBoard.title}`
await this.insertBlocks(
newBlocks,
description,
async () => {
await afterRedo?.(newBoardId)
},
beforeUndo,
)
return [newBlocks, newBoardId]
}
// Other methods
// Not a mutator, but convenient to put here since Mutator wraps OctoClient

View File

@ -14,8 +14,8 @@ class OctoClient {
Utils.log(`OctoClient serverUrl: ${this.serverUrl}`)
}
async getSubtree(rootId?: string): Promise<IBlock[]> {
const path = `/api/v1/blocks/${rootId}/subtree`
async getSubtree(rootId?: string, levels = 2): Promise<IBlock[]> {
const path = `/api/v1/blocks/${rootId}/subtree?l=${levels}`
const response = await fetch(this.serverUrl + path)
const blocks = (await response.json() || []) as IMutableBlock[]
this.fixBlocks(blocks)
@ -36,7 +36,7 @@ class OctoClient {
Utils.log(`\t ${block.type}, ${block.id}`)
})
const body = JSON.stringify(blocks)
return await fetch(this.serverUrl + '/api/v1/blocks/import', {
return fetch(this.serverUrl + '/api/v1/blocks/import', {
method: 'POST',
headers: {
Accept: 'application/json',
@ -68,35 +68,22 @@ class OctoClient {
return blocks
}
// TODO: Remove this fixup code
fixBlocks(blocks: IMutableBlock[]): void {
if (!blocks) {
return
}
// TODO
for (const block of blocks) {
if (!block.fields) {
block.fields = {}
}
const o = block as any
if (o.cardProperties) {
block.fields.cardProperties = o.cardProperties; delete o.cardProperties
}
if (o.properties) {
block.fields.properties = o.properties; delete o.properties
}
if (o.icon) {
block.fields.icon = o.icon; delete o.icon
}
if (o.url) {
block.fields.url = o.url; delete o.url
}
}
}
async updateBlock(block: IMutableBlock): Promise<Response> {
block.updateAt = Date.now()
return await this.insertBlocks([block])
return this.insertBlocks([block])
}
async updateBlocks(blocks: IMutableBlock[]): Promise<Response> {
@ -104,12 +91,12 @@ class OctoClient {
blocks.forEach((block) => {
block.updateAt = now
})
return await this.insertBlocks(blocks)
return this.insertBlocks(blocks)
}
async deleteBlock(blockId: string): Promise<Response> {
Utils.log(`deleteBlock: ${blockId}`)
return await fetch(this.serverUrl + `/api/v1/blocks/${encodeURIComponent(blockId)}`, {
return fetch(this.serverUrl + `/api/v1/blocks/${encodeURIComponent(blockId)}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
@ -125,10 +112,10 @@ class OctoClient {
async insertBlocks(blocks: IBlock[]): Promise<Response> {
Utils.log(`insertBlocks: ${blocks.length} blocks(s)`)
blocks.forEach((block) => {
Utils.log(`\t ${block.type}, ${block.id}`)
Utils.log(`\t ${block.type}, ${block.id}, ${block.title?.substr(0, 50) || ''}`)
})
const body = JSON.stringify(blocks)
return await fetch(this.serverUrl + '/api/v1/blocks', {
return fetch(this.serverUrl + '/api/v1/blocks', {
method: 'POST',
headers: {
Accept: 'application/json',

View File

@ -1,5 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IBlock} from './blocks/block'
import {Utils} from './utils'
// These are outgoing commands to the server
@ -12,14 +13,17 @@ type WSCommand = {
type WSMessage = {
action: string
blockId: string
block: IBlock
}
type OnChangeHandler = (blocks: IBlock[]) => void
//
// OctoListener calls a handler when a block or any of its children changes
//
class OctoListener {
get isOpen(): boolean {
return this.ws !== undefined
return Boolean(this.ws)
}
readonly serverUrl: string
@ -27,7 +31,11 @@ class OctoListener {
private blockIds: string[] = []
private isInitialized = false
notificationDelay = 200
private onChange?: OnChangeHandler
private updatedBlocks: IBlock[] = []
private updateTimeout?: NodeJS.Timeout
notificationDelay = 100
reopenDelay = 3000
constructor(serverUrl?: string) {
@ -35,13 +43,15 @@ class OctoListener {
Utils.log(`OctoListener serverUrl: ${this.serverUrl}`)
}
open(blockIds: string[], onChange: (blockId: string) => void) {
open(blockIds: string[], onChange: OnChangeHandler, onReconnect: () => void): void {
let timeoutId: NodeJS.Timeout
if (this.ws) {
this.close()
}
this.onChange = onChange
const url = new URL(this.serverUrl)
const wsServerUrl = `ws://${url.host}${url.pathname}ws/onchange`
Utils.log(`OctoListener open: ${wsServerUrl}`)
@ -65,13 +75,14 @@ class OctoListener {
const reopenBlockIds = this.isInitialized ? this.blockIds.slice() : blockIds.slice()
Utils.logError(`Unexpected close, re-opening with ${reopenBlockIds.length} blocks...`)
setTimeout(() => {
this.open(reopenBlockIds, onChange)
this.open(reopenBlockIds, onChange, onReconnect)
onReconnect()
}, this.reopenDelay)
}
}
ws.onmessage = (e) => {
Utils.log(`OctoListener websocket onmessage. data: ${e.data}`)
// Utils.log(`OctoListener websocket onmessage. data: ${e.data}`)
if (ws !== this.ws) {
Utils.log('Ignoring closed ws')
return
@ -84,10 +95,8 @@ class OctoListener {
if (timeoutId) {
clearTimeout(timeoutId)
}
timeoutId = setTimeout(() => {
timeoutId = undefined
onChange(message.blockId)
}, this.notificationDelay)
Utils.log(`OctoListener update block: ${message.block?.id}`)
this.queueUpdateNotification(message.block)
break
default:
Utils.logError(`Unexpected action: ${message.action}`)
@ -98,7 +107,7 @@ class OctoListener {
}
}
close() {
close(): void {
if (!this.ws) {
return
}
@ -109,12 +118,13 @@ class OctoListener {
const ws = this.ws
this.ws = undefined
this.blockIds = []
this.onChange = undefined
this.isInitialized = false
ws.close()
}
addBlocks(blockIds: string[]): void {
if (!this.isOpen) {
if (!this.ws) {
Utils.assertFailure('OctoListener.addBlocks: ws is not open')
return
}
@ -129,7 +139,7 @@ class OctoListener {
}
removeBlocks(blockIds: string[]): void {
if (!this.isOpen) {
if (!this.ws) {
Utils.assertFailure('OctoListener.removeBlocks: ws is not open')
return
}
@ -151,6 +161,24 @@ class OctoListener {
}
}
}
private queueUpdateNotification(block: IBlock) {
this.updatedBlocks = this.updatedBlocks.filter((o) => o.id !== block.id) // Remove existing queued update
this.updatedBlocks.push(block)
if (this.updateTimeout) {
clearTimeout(this.updateTimeout)
this.updateTimeout = undefined
}
this.updateTimeout = setTimeout(() => {
this.flushUpdateNotifications()
}, this.notificationDelay)
}
private flushUpdateNotifications() {
this.onChange?.(this.updatedBlocks)
this.updatedBlocks = []
}
}
export {OctoListener}

View File

@ -1,21 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {IBlock, MutableBlock} from './blocks/block'
import {IPropertyTemplate, MutableBoard} from './blocks/board'
import {MutableBoardView} from './blocks/boardView'
import {MutableCard} from './blocks/card'
import {MutableCommentBlock} from './blocks/commentBlock'
import {MutableImageBlock} from './blocks/imageBlock'
import {MutableDividerBlock} from './blocks/dividerBlock'
import {MutableImageBlock} from './blocks/imageBlock'
import {IOrderedBlock} from './blocks/orderedBlock'
import {MutableTextBlock} from './blocks/textBlock'
import {Utils} from './utils'
class OctoUtils {
static propertyDisplayValue(block: IBlock, propertyValue: string | undefined, propertyTemplate: IPropertyTemplate): string | undefined {
let displayValue: string
let displayValue: string | undefined
switch (propertyTemplate.type) {
case 'select': {
// The property value is the id of the template
@ -80,6 +78,42 @@ class OctoUtils {
static hydrateBlocks(blocks: IBlock[]): MutableBlock[] {
return blocks.map((block) => this.hydrateBlock(block))
}
static mergeBlocks(blocks: IBlock[], updatedBlocks: IBlock[]): IBlock[] {
const updatedBlockIds = updatedBlocks.map((o) => o.id)
const newBlocks = blocks.filter((o) => !updatedBlockIds.includes(o.id))
const updatedAndNotDeletedBlocks = updatedBlocks.filter((o) => o.deleteAt === 0)
newBlocks.push(...updatedAndNotDeletedBlocks)
return newBlocks
}
// Creates a copy of the blocks with new ids and parentIDs
static duplicateBlockTree(blocks: IBlock[], rootBlockId?: string): [MutableBlock[], Readonly<Record<string, string>>] {
const idMap: Record<string, string> = {}
const newBlocks = blocks.map((block) => {
const newBlock = this.hydrateBlock(block)
newBlock.id = Utils.createGuid()
idMap[block.id] = newBlock.id
return newBlock
})
const newRootBlockId = rootBlockId ? idMap[rootBlockId] : undefined
newBlocks.forEach((newBlock) => {
// Note: Don't remap the parent of the new root block
if (newBlock.id !== newRootBlockId && newBlock.parentId) {
newBlock.parentId = idMap[newBlock.parentId] || newBlock.parentId
Utils.assert(newBlock.parentId, `Block ${newBlock.id} (${newBlock.type} ${newBlock.title}) has no parent`)
}
// Remap manual card order
if (newBlock.type === 'view') {
const view = newBlock as MutableBoardView
view.cardOrder = view.cardOrder.map((o) => idMap[o])
}
})
return [newBlocks, idMap]
}
}
export {OctoUtils}

View File

@ -2,14 +2,14 @@
// See LICENSE.txt for license information.
import React from 'react'
import {BoardView} from '../blocks/boardView'
import {MutableBoardTree} from '../viewModel/boardTree'
import {WorkspaceComponent} from '../components/workspaceComponent'
import {IBlock} from '../blocks/block'
import {sendFlashMessage} from '../components/flashMessages'
import {WorkspaceComponent} from '../components/workspaceComponent'
import mutator from '../mutator'
import {OctoListener} from '../octoListener'
import {Utils} from '../utils'
import {MutableWorkspaceTree} from '../viewModel/workspaceTree'
import {BoardTree, MutableBoardTree} from '../viewModel/boardTree'
import {MutableWorkspaceTree, WorkspaceTree} from '../viewModel/workspaceTree'
type Props = {
setLanguage: (lang: string) => void
@ -18,23 +18,18 @@ type Props = {
type State = {
boardId: string
viewId: string
workspaceTree: MutableWorkspaceTree
boardTree?: MutableBoardTree
workspaceTree: WorkspaceTree
boardTree?: BoardTree
}
export default class BoardPage extends React.Component<Props, State> {
view: BoardView
updateTitleTimeout: number
updatePropertyLabelTimeout: number
private workspaceListener = new OctoListener()
constructor(props: Props) {
super(props)
const queryString = new URLSearchParams(window.location.search)
const boardId = queryString.get('id')
const viewId = queryString.get('v')
const boardId = queryString.get('id') || ''
const viewId = queryString.get('v') || ''
this.state = {
boardId,
@ -65,7 +60,7 @@ export default class BoardPage extends React.Component<Props, State> {
}
}
undoRedoHandler = async (e: KeyboardEvent) => {
private undoRedoHandler = async (e: KeyboardEvent) => {
if (e.target !== document.body) {
return
}
@ -144,41 +139,63 @@ export default class BoardPage extends React.Component<Props, State> {
}
private async sync(boardId: string = this.state.boardId, viewId: string | undefined = this.state.viewId) {
const {workspaceTree} = this.state
Utils.log(`sync start: ${boardId}`)
const workspaceTree = new MutableWorkspaceTree()
await workspaceTree.sync()
const boardIds = workspaceTree.boards.map((o) => o.id)
this.setState({workspaceTree})
// Listen to boards plus all blocks at root (Empty string for parentId)
this.workspaceListener.open(['', ...boardIds], async (blockId) => {
Utils.log(`workspaceListener.onChanged: ${blockId}`)
this.sync()
})
this.workspaceListener.open(
['', ...boardIds],
async (blocks) => {
Utils.log(`workspaceListener.onChanged: ${blocks.length}`)
this.incrementalUpdate(blocks)
},
() => {
Utils.log('workspaceListener.onReconnect')
this.sync()
},
)
if (boardId) {
const boardTree = new MutableBoardTree(boardId)
await boardTree.sync()
// Default to first view
if (!viewId) {
viewId = boardTree.views[0].id
}
boardTree.setActiveView(viewId)
boardTree.setActiveView(viewId || boardTree.views[0].id)
// TODO: Handle error (viewId not found)
this.setState({
boardTree,
boardId,
viewId: boardTree.activeView.id,
viewId: boardTree.activeView!.id,
})
Utils.log(`sync complete: ${boardTree.board.id} (${boardTree.board.title})`)
} else {
this.forceUpdate()
}
}
private incrementalUpdate(blocks: IBlock[]) {
const {workspaceTree, boardTree} = this.state
let newState = {workspaceTree, boardTree}
const newWorkspaceTree = workspaceTree.mutableCopy()
if (newWorkspaceTree.incrementalUpdate(blocks)) {
newState = {...newState, workspaceTree: newWorkspaceTree}
}
const newBoardTree = boardTree ? boardTree.mutableCopy() : new MutableBoardTree(this.state.boardId)
if (newBoardTree.incrementalUpdate(blocks)) {
newBoardTree.setActiveView(this.state.viewId)
newState = {...newState, boardTree: newBoardTree}
}
this.setState(newState)
}
// IPageController
showBoard(boardId: string): void {
const {boardTree} = this.state
@ -194,9 +211,10 @@ export default class BoardPage extends React.Component<Props, State> {
}
showView(viewId: string, boardId: string = this.state.boardId): void {
if (this.state.boardId === boardId) {
this.state.boardTree.setActiveView(viewId)
this.setState({...this.state, viewId})
if (this.state.boardTree && this.state.boardId === boardId) {
const newBoardTree = this.state.boardTree.mutableCopy()
newBoardTree.setActiveView(viewId)
this.setState({boardTree: newBoardTree, viewId})
} else {
this.attachToBoard(boardId, viewId)
}
@ -206,7 +224,13 @@ export default class BoardPage extends React.Component<Props, State> {
}
setSearchText(text?: string): void {
this.state.boardTree?.setSearchText(text)
this.setState({...this.state, boardTree: this.state.boardTree})
if (!this.state.boardTree) {
Utils.assertFailure('setSearchText: boardTree')
return
}
const newBoardTree = this.state.boardTree.mutableCopy()
newBoardTree.setSearchText(text)
this.setState({boardTree: newBoardTree})
}
}

View File

@ -3,25 +3,24 @@
import React from 'react'
import {Utils} from '../utils'
import Button from '../widgets/buttons/button'
import './loginPage.scss'
type Props = {}
type State = {
username: string;
password: string;
type Props = {
}
export default class LoginPage extends React.Component<Props, State> {
type State = {
username: string
password: string
}
export default class LoginPage extends React.PureComponent<Props, State> {
state = {
username: '',
password: '',
}
handleLogin = () => {
private handleLogin = (): void => {
Utils.log('Logging in')
}
@ -29,7 +28,7 @@ export default class LoginPage extends React.Component<Props, State> {
return (
<div className='LoginPage'>
<div className='username'>
<label htmlFor='login-username'>Username</label>
<label htmlFor='login-username'>{'Username'}</label>
<input
id='login-username'
value={this.state.username}
@ -37,7 +36,7 @@ export default class LoginPage extends React.Component<Props, State> {
/>
</div>
<div className='password'>
<label htmlFor='login-username'>Password</label>
<label htmlFor='login-username'>{'Password'}</label>
<input
id='login-password'
type='password'
@ -45,7 +44,7 @@ export default class LoginPage extends React.Component<Props, State> {
onChange={(e) => this.setState({password: e.target.value})}
/>
</div>
<Button onClick={this.handleLogin}>Login</Button>
<Button onClick={this.handleLogin}>{'Login'}</Button>
</div>
)
}

View File

@ -0,0 +1,124 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import undoManager from './undomanager'
import {Utils} from './utils'
test('Basic undo/redo', async () => {
expect(undoManager.canUndo).toBe(false)
expect(undoManager.canRedo).toBe(false)
const values: string[] = []
await undoManager.perform(
async () => {
values.push('a')
},
async () => {
values.pop()
},
'test',
)
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(false)
expect(Utils.arraysEqual(values, ['a'])).toBe(true)
expect(undoManager.undoDescription).toBe('test')
expect(undoManager.redoDescription).toBe(undefined)
await undoManager.undo()
expect(undoManager.canUndo).toBe(false)
expect(undoManager.canRedo).toBe(true)
expect(Utils.arraysEqual(values, [])).toBe(true)
expect(undoManager.undoDescription).toBe(undefined)
expect(undoManager.redoDescription).toBe('test')
await undoManager.redo()
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(false)
expect(Utils.arraysEqual(values, ['a'])).toBe(true)
await undoManager.clear()
expect(undoManager.canUndo).toBe(false)
expect(undoManager.canRedo).toBe(false)
expect(undoManager.undoDescription).toBe(undefined)
expect(undoManager.redoDescription).toBe(undefined)
})
test('Grouped undo/redo', async () => {
expect(undoManager.canUndo).toBe(false)
expect(undoManager.canRedo).toBe(false)
const values: string[] = []
const groupId = 'the group id'
await undoManager.perform(
async () => {
values.push('a')
},
async () => {
values.pop()
},
'insert a',
)
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(false)
expect(Utils.arraysEqual(values, ['a'])).toBe(true)
expect(undoManager.undoDescription).toBe('insert a')
expect(undoManager.redoDescription).toBe(undefined)
await undoManager.perform(
async () => {
values.push('b')
},
async () => {
values.pop()
},
'insert b',
groupId,
)
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(false)
expect(Utils.arraysEqual(values, ['a', 'b'])).toBe(true)
expect(undoManager.undoDescription).toBe('insert b')
expect(undoManager.redoDescription).toBe(undefined)
await undoManager.perform(
async () => {
values.push('c')
},
async () => {
values.pop()
},
'insert c',
groupId,
)
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(false)
expect(Utils.arraysEqual(values, ['a', 'b', 'c'])).toBe(true)
expect(undoManager.undoDescription).toBe('insert c')
expect(undoManager.redoDescription).toBe(undefined)
await undoManager.undo()
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(true)
expect(Utils.arraysEqual(values, ['a'])).toBe(true)
expect(undoManager.undoDescription).toBe('insert a')
expect(undoManager.redoDescription).toBe('insert b')
await undoManager.redo()
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(false)
expect(Utils.arraysEqual(values, ['a', 'b', 'c'])).toBe(true)
expect(undoManager.undoDescription).toBe('insert c')
expect(undoManager.redoDescription).toBe(undefined)
await undoManager.clear()
expect(undoManager.canUndo).toBe(false)
expect(undoManager.canRedo).toBe(false)
expect(undoManager.undoDescription).toBe(undefined)
expect(undoManager.redoDescription).toBe(undefined)
})

View File

@ -128,11 +128,17 @@ class UndoManager {
}
const currentGroupId = command.groupId
do {
if (currentGroupId) {
do {
// eslint-disable-next-line no-await-in-loop
await this.execute(command, 'undo')
this.index -= 1
command = this.commands[this.index]
} while (this.index >= 0 && currentGroupId === command.groupId)
} else {
await this.execute(command, 'undo')
this.index -= 1
command = this.commands[this.index]
} while (this.index >= 0 && currentGroupId && currentGroupId === command.groupId)
}
if (this.onStateDidChange) {
this.onStateDidChange()
@ -151,11 +157,17 @@ class UndoManager {
}
const currentGroupId = command.groupId
do {
if (currentGroupId) {
do {
// eslint-disable-next-line no-await-in-loop
await this.execute(command, 'redo')
this.index += 1
command = this.commands[this.index + 1]
} while (this.index < this.commands.length - 1 && currentGroupId === command.groupId)
} else {
await this.execute(command, 'redo')
this.index += 1
command = this.commands[this.index + 1]
} while (this.index < this.commands.length && currentGroupId && currentGroupId === command.groupId)
}
if (this.onStateDidChange) {
this.onStateDidChange()

View File

@ -1,12 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IBlock, IMutableBlock} from '../blocks/block'
import {Board, IPropertyOption, IPropertyTemplate, MutableBoard} from '../blocks/board'
import {BoardView, MutableBoardView} from '../blocks/boardView'
import {Card, MutableCard} from '../blocks/card'
import {CardFilter} from '../cardFilter'
import {Constants} from '../constants'
import octoClient from '../octoClient'
import {IBlock, IMutableBlock} from '../blocks/block'
import {OctoUtils} from '../octoUtils'
import {Utils} from '../utils'
@ -19,46 +19,65 @@ interface BoardTree {
readonly board: Board
readonly views: readonly BoardView[]
readonly cards: readonly Card[]
readonly cardTemplates: readonly Card[]
readonly allCards: readonly Card[]
readonly visibleGroups: readonly Group[]
readonly hiddenGroups: readonly Group[]
readonly allBlocks: readonly IBlock[]
readonly activeView?: BoardView
readonly activeView: BoardView
readonly groupByProperty?: IPropertyTemplate
getSearchText(): string | undefined
orderedCards(): Card[]
mutableCopy(): MutableBoardTree
}
class MutableBoardTree implements BoardTree {
board!: MutableBoard
views: MutableBoardView[] = []
cards: MutableCard[] = []
cardTemplates: MutableCard[] = []
visibleGroups: Group[] = []
hiddenGroups: Group[] = []
activeView?: MutableBoardView
activeView!: MutableBoardView
groupByProperty?: IPropertyTemplate
private rawBlocks: IBlock[] = []
private searchText?: string
allCards: MutableCard[] = []
get allBlocks(): IBlock[] {
return [this.board, ...this.views, ...this.allCards]
return [this.board, ...this.views, ...this.allCards, ...this.cardTemplates]
}
constructor(private boardId: string) {
}
async sync() {
const blocks = await octoClient.getSubtree(this.boardId)
this.rebuild(OctoUtils.hydrateBlocks(blocks))
async sync(): Promise<void> {
this.rawBlocks = await octoClient.getSubtree(this.boardId)
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
}
incrementalUpdate(updatedBlocks: IBlock[]): boolean {
const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.id === this.boardId || block.parentId === this.boardId)
if (relevantBlocks.length < 1) {
return false
}
this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, relevantBlocks)
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
return true
}
private rebuild(blocks: IMutableBlock[]) {
this.board = blocks.find((block) => block.type === 'board') as MutableBoard
this.views = blocks.filter((block) => block.type === 'view') as MutableBoardView[]
this.allCards = blocks.filter((block) => block.type === 'card') as MutableCard[]
this.views = blocks.filter((block) => block.type === 'view').
sort((a, b) => a.title.localeCompare(b.title)) as MutableBoardView[]
this.allCards = blocks.filter((block) => block.type === 'card' && !(block as Card).isTemplate) as MutableCard[]
this.cardTemplates = blocks.filter((block) => block.type === 'card' && (block as Card).isTemplate).
sort((a, b) => a.title.localeCompare(b.title)) as MutableCard[]
this.cards = []
this.ensureMinimumSchema()
@ -93,16 +112,22 @@ class MutableBoardTree implements BoardTree {
didChange = true
}
if (!this.activeView) {
this.activeView = this.views[0]
}
return didChange
}
setActiveView(viewId: string) {
this.activeView = this.views.find((o) => o.id === viewId)
if (!this.activeView) {
setActiveView(viewId: string): void {
let view = this.views.find((o) => o.id === viewId)
if (!view) {
Utils.logError(`Cannot find BoardView: ${viewId}`)
this.activeView = this.views[0]
view = this.views[0]
}
this.activeView = view
// Fix missing group by (e.g. for new views)
if (this.activeView.viewType === 'board' && !this.activeView.groupById) {
this.activeView.groupById = this.board.cardProperties.find((o) => o.type === 'select')?.id
@ -115,12 +140,12 @@ class MutableBoardTree implements BoardTree {
return this.searchText
}
setSearchText(text?: string) {
setSearchText(text?: string): void {
this.searchText = text
this.applyFilterSortAndGroup()
}
applyFilterSortAndGroup() {
private applyFilterSortAndGroup(): void {
Utils.assert(this.allCards !== undefined)
this.cards = this.filterCards(this.allCards) as MutableCard[]
@ -145,11 +170,7 @@ class MutableBoardTree implements BoardTree {
return cards.slice()
}
return cards.filter((card) => {
if (card.title?.toLocaleLowerCase().indexOf(searchText) !== -1) {
return true
}
})
return cards.filter((card) => card.title?.toLocaleLowerCase().indexOf(searchText) !== -1)
}
private setGroupByProperty(propertyId: string) {
@ -170,6 +191,10 @@ class MutableBoardTree implements BoardTree {
private groupCards() {
const {activeView, groupByProperty} = this
if (!activeView || !groupByProperty) {
Utils.assertFailure('groupCards')
return
}
const unassignedOptionIds = groupByProperty.options.
filter((o) => !activeView.visibleOptionIds.includes(o.id) && !activeView.hiddenOptionIds.includes(o.id)).
@ -203,9 +228,9 @@ class MutableBoardTree implements BoardTree {
}
} else {
// Empty group
const emptyGroupCards = this.cards.filter((o) => {
const optionId = o.properties[groupByProperty.id]
return !optionId || !this.groupByProperty.options.find((option) => option.id === optionId)
const emptyGroupCards = this.cards.filter((card) => {
const groupByOptionId = card.properties[groupByProperty.id]
return !groupByOptionId || !groupByProperty.options.find((option) => option.id === groupByOptionId)
})
const group: Group = {
option: {id: '', value: `No ${groupByProperty.name}`, color: ''},
@ -220,7 +245,7 @@ class MutableBoardTree implements BoardTree {
private filterCards(cards: MutableCard[]): Card[] {
const {board} = this
const filterGroup = this.activeView?.filter
const filterGroup = this.activeView.filter
if (!filterGroup) {
return cards.slice()
}
@ -228,27 +253,32 @@ class MutableBoardTree implements BoardTree {
return CardFilter.applyFilterGroup(filterGroup, board.cardProperties, cards)
}
private defaultOrder(cardA: Card, cardB: Card) {
const {activeView} = this
private titleOrCreatedOrder(cardA: Card, cardB: Card) {
const aValue = cardA.title
const bValue = cardB.title
if (aValue && bValue) {
return aValue.localeCompare(bValue)
}
// Always put untitled cards at the bottom
if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
// If both cards are untitled, use the create date
return cardA.createAt - cardB.createAt
}
private manualOrder(activeView: BoardView, cardA: Card, cardB: Card) {
const indexA = activeView.cardOrder.indexOf(cardA.id)
const indexB = activeView.cardOrder.indexOf(cardB.id)
if (indexA < 0 && indexB < 0) {
// If both cards' order is not defined, first use the title
const aValue = cardA.title || ''
const bValue = cardB.title || ''
// Always put untitled cards at the bottom
if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
// If both cards are untitled, use the create date
return cardA.createAt - cardB.createAt
return this.titleOrCreatedOrder(cardA, cardB)
} else if (indexA < 0 && indexB >= 0) {
// If cardA's order is not defined, put it at the end
return 1
@ -257,26 +287,45 @@ class MutableBoardTree implements BoardTree {
}
private sortCards(cards: Card[]): Card[] {
if (!this.activeView) {
const {board, activeView} = this
if (!activeView) {
Utils.assertFailure()
return cards
}
const {board} = this
const {sortOptions} = this.activeView
let sortedCards: Card[] = []
const {sortOptions} = activeView
if (sortOptions.length < 1) {
Utils.log('Default sort')
sortedCards = cards.sort((a, b) => this.defaultOrder(a, b))
} else {
sortOptions.forEach((sortOption) => {
if (sortOption.propertyId === Constants.titleColumnId) {
Utils.log('Sort by name')
sortedCards = cards.sort((a, b) => {
const aValue = a.title || ''
const bValue = b.title || ''
Utils.log('Manual sort')
return cards.sort((a, b) => this.manualOrder(activeView, a, b))
}
// Always put empty values at the bottom, newest last
let sortedCards = cards
for (const sortOption of sortOptions) {
if (sortOption.propertyId === Constants.titleColumnId) {
Utils.log('Sort by title')
sortedCards = sortedCards.sort((a, b) => {
const result = this.titleOrCreatedOrder(a, b)
return sortOption.reversed ? -result : result
})
} else {
const sortPropertyId = sortOption.propertyId
const template = board.cardProperties.find((o) => o.id === sortPropertyId)
if (!template) {
Utils.logError(`Missing template for property id: ${sortPropertyId}`)
return sortedCards
}
Utils.log(`Sort by property: ${template?.name}`)
sortedCards = sortedCards.sort((a, b) => {
// Always put cards with no titles at the bottom, regardless of sort
if (!a.title || !b.title) {
return this.titleOrCreatedOrder(a, b)
}
const aValue = a.properties[sortPropertyId] || ''
const bValue = b.properties[sortPropertyId] || ''
let result = 0
if (template.type === 'select') {
// Always put empty values at the bottom
if (aValue && !bValue) {
return -1
}
@ -284,96 +333,56 @@ class MutableBoardTree implements BoardTree {
return 1
}
if (!aValue && !bValue) {
return this.defaultOrder(a, b)
return this.titleOrCreatedOrder(a, b)
}
let result = aValue.localeCompare(bValue)
if (sortOption.reversed) {
result = -result
}
return result
})
} else {
const sortPropertyId = sortOption.propertyId
const template = board.cardProperties.find((o) => o.id === sortPropertyId)
if (!template) {
Utils.logError(`Missing template for property id: ${sortPropertyId}`)
return cards.slice()
}
Utils.log(`Sort by ${template?.name}`)
sortedCards = cards.sort((a, b) => {
// Always put cards with no titles at the bottom
if (a.title && !b.title) {
// Sort by the option order (not alphabetically by value)
const aOrder = template.options.findIndex((o) => o.id === aValue)
const bOrder = template.options.findIndex((o) => o.id === bValue)
result = aOrder - bOrder
} else if (template.type === 'number' || template.type === 'date') {
// Always put empty values at the bottom
if (aValue && !bValue) {
return -1
}
if (b.title && !a.title) {
if (bValue && !aValue) {
return 1
}
if (!a.title && !b.title) {
return this.defaultOrder(a, b)
if (!aValue && !bValue) {
return this.titleOrCreatedOrder(a, b)
}
const aValue = a.properties[sortPropertyId] || ''
const bValue = b.properties[sortPropertyId] || ''
let result = 0
if (template.type === 'select') {
// Always put empty values at the bottom
if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
if (!aValue && !bValue) {
return this.defaultOrder(a, b)
}
result = Number(aValue) - Number(bValue)
} else if (template.type === 'createdTime') {
result = a.createAt - b.createAt
} else if (template.type === 'updatedTime') {
result = a.updateAt - b.updateAt
} else {
// Text-based sort
// Sort by the option order (not alphabetically by value)
const aOrder = template.options.findIndex((o) => o.id === aValue)
const bOrder = template.options.findIndex((o) => o.id === bValue)
result = aOrder - bOrder
} else if (template.type === 'number' || template.type === 'date') {
// Always put empty values at the bottom
if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
if (!aValue && !bValue) {
return this.defaultOrder(a, b)
}
result = Number(aValue) - Number(bValue)
} else if (template.type === 'createdTime') {
result = this.defaultOrder(a, b)
} else if (template.type === 'updatedTime') {
result = a.updateAt - b.updateAt
} else {
// Text-based sort
// Always put empty values at the bottom
if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
if (!aValue && !bValue) {
return this.defaultOrder(a, b)
}
result = aValue.localeCompare(bValue)
// Always put empty values at the bottom
if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
if (!aValue && !bValue) {
return this.titleOrCreatedOrder(a, b)
}
if (sortOption.reversed) {
result = -result
}
return result
})
}
})
result = aValue.localeCompare(bValue)
}
if (result === 0) {
// In case of "ties", use the title order
result = this.titleOrCreatedOrder(a, b)
}
return sortOption.reversed ? -result : result
})
}
}
return sortedCards
@ -390,6 +399,12 @@ class MutableBoardTree implements BoardTree {
return cards
}
mutableCopy(): MutableBoardTree {
const boardTree = new MutableBoardTree(this.boardId)
boardTree.incrementalUpdate(this.rawBlocks)
return boardTree
}
}
export {MutableBoardTree, BoardTree, Group as BoardTreeGroup}

View File

@ -1,32 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Card} from '../blocks/card'
import {IBlock, MutableBlock} from '../blocks/block'
import {Card, MutableCard} from '../blocks/card'
import {IOrderedBlock} from '../blocks/orderedBlock'
import octoClient from '../octoClient'
import {IBlock} from '../blocks/block'
import {OctoUtils} from '../octoUtils'
interface CardTree {
readonly card: Card
readonly comments: readonly IBlock[]
readonly contents: readonly IOrderedBlock[]
mutableCopy(): MutableCardTree
templateCopy(): MutableCardTree
}
class MutableCardTree implements CardTree {
card: Card
comments: IBlock[]
contents: IOrderedBlock[]
card!: MutableCard
comments: IBlock[] = []
contents: IOrderedBlock[] = []
private rawBlocks: IBlock[] = []
constructor(private cardId: string) {
}
async sync() {
const blocks = await octoClient.getSubtree(this.cardId)
this.rebuild(OctoUtils.hydrateBlocks(blocks))
async sync(): Promise<void> {
this.rawBlocks = await octoClient.getSubtree(this.cardId)
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
}
incrementalUpdate(updatedBlocks: IBlock[]): boolean {
const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.id === this.cardId || block.parentId === this.cardId)
if (relevantBlocks.length < 1) {
return false
}
this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, relevantBlocks)
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
return true
}
private rebuild(blocks: IBlock[]) {
this.card = blocks.find((o) => o.id === this.cardId) as Card
this.card = blocks.find((o) => o.id === this.cardId) as MutableCard
this.comments = blocks.
filter((block) => block.type === 'comment').
@ -35,6 +50,26 @@ class MutableCardTree implements CardTree {
const contentBlocks = blocks.filter((block) => block.type === 'text' || block.type === 'image' || block.type === 'divider') as IOrderedBlock[]
this.contents = contentBlocks.sort((a, b) => a.order - b.order)
}
mutableCopy(): MutableCardTree {
const cardTree = new MutableCardTree(this.cardId)
cardTree.incrementalUpdate(this.rawBlocks)
return cardTree
}
templateCopy(): MutableCardTree {
const card = this.card.duplicate()
const contents: IOrderedBlock[] = this.contents.map((content) => {
const copy = MutableBlock.duplicate(content)
copy.parentId = card.id
return copy as IOrderedBlock
})
const cardTree = new MutableCardTree(card.id)
cardTree.incrementalUpdate([card, ...contents])
return cardTree
}
}
export {MutableCardTree, CardTree}

View File

@ -1,35 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Board} from '../blocks/board'
import octoClient from '../octoClient'
import {IBlock} from '../blocks/block'
import {OctoUtils} from '../octoUtils'
import {Board} from '../blocks/board'
import {BoardView} from '../blocks/boardView'
import octoClient from '../octoClient'
import {OctoUtils} from '../octoUtils'
interface WorkspaceTree {
readonly boards: readonly Board[]
readonly views: readonly BoardView[]
mutableCopy(): MutableWorkspaceTree
}
class MutableWorkspaceTree {
boards: Board[] = []
views: BoardView[] = []
async sync() {
const boards = await octoClient.getBlocksWithType('board')
const views = await octoClient.getBlocksWithType('view')
this.rebuild(
OctoUtils.hydrateBlocks(boards),
OctoUtils.hydrateBlocks(views),
)
private rawBlocks: IBlock[] = []
async sync(): Promise<void> {
const rawBoards = await octoClient.getBlocksWithType('board')
const rawViews = await octoClient.getBlocksWithType('view')
this.rawBlocks = [...rawBoards, ...rawViews]
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
}
private rebuild(boards: IBlock[], views: IBlock[]) {
this.boards = boards.filter((block) => block.type === 'board') as Board[]
this.views = views.filter((block) => block.type === 'view') as BoardView[]
incrementalUpdate(updatedBlocks: IBlock[]): boolean {
const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.type === 'board' || block.type === 'view')
if (relevantBlocks.length < 1) {
return false
}
this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, updatedBlocks)
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
return true
}
private rebuild(blocks: IBlock[]) {
this.boards = blocks.filter((block) => block.type === 'board').
sort((a, b) => a.title.localeCompare(b.title)) as Board[]
this.views = blocks.filter((block) => block.type === 'view').
sort((a, b) => a.title.localeCompare(b.title)) as BoardView[]
}
mutableCopy(): MutableWorkspaceTree {
const workspaceTree = new MutableWorkspaceTree()
workspaceTree.incrementalUpdate(this.rawBlocks)
return workspaceTree
}
}
// type WorkspaceTree = Readonly<MutableWorkspaceTree>
export {MutableWorkspaceTree, WorkspaceTree}

View File

@ -10,8 +10,8 @@ type Props = {
icon?: React.ReactNode
}
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

@ -9,9 +9,10 @@ type Props = {
value?: string
placeholderText?: string
className?: string
saveOnEsc?: boolean
onCancel?: () => void
onSave?: (saveType: 'onEnter'|'onBlur') => void
onSave?: (saveType: 'onEnter'|'onEsc'|'onBlur') => void
}
export default class Editable extends React.Component<Props> {
@ -23,16 +24,16 @@ export default class Editable extends React.Component<Props> {
}
public focus(): void {
this.elementRef.current.focus()
this.elementRef.current!.focus()
// Put cursor at end
document.execCommand('selectAll', false, null)
document.getSelection().collapseToEnd()
document.execCommand('selectAll', false, undefined)
document.getSelection()?.collapseToEnd()
}
public blur = (): void => {
this.saveOnBlur = false
this.elementRef.current.blur()
this.elementRef.current!.blur()
this.saveOnBlur = true
}
@ -52,15 +53,15 @@ export default class Editable extends React.Component<Props> {
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC
e.stopPropagation()
if (this.props.onCancel) {
this.props.onCancel()
if (this.props.saveOnEsc) {
this.props.onSave?.('onEsc')
} else {
this.props.onCancel?.()
}
this.blur()
} else if (e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Return
e.stopPropagation()
if (this.props.onSave) {
this.props.onSave('onEnter')
}
this.props.onSave?.('onEnter')
this.blur()
}
}}

View File

@ -0,0 +1,6 @@
.DisclosureTriangleIcon {
fill: rgba(var(--main-fg), 0.7);
stroke: none;
width: 24px;
height: 24px;
}

View File

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import './disclosureTriangle.scss'
export default function DisclosureTriangle(): JSX.Element {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
className='DisclosureTriangleIcon Icon'
viewBox='0 0 100 100'
>
<polygon points='37,35 37,65 63,50'/>
</svg>
)
}

View File

@ -11,7 +11,8 @@ type ColorOptionProps = MenuOptionProps & {
}
export default class ColorOption extends React.PureComponent<ColorOptionProps> {
private handleOnClick = (): void => {
private handleOnClick = (e: React.MouseEvent): void => {
e.target.dispatchEvent(new Event('menuItemClicked'))
this.props.onClick(this.props.id)
}

View File

@ -19,6 +19,8 @@
display: flex;
flex-direction: column;
flex-grow: 1;
list-style: none;
padding: 0;
margin: 0;
@ -31,17 +33,23 @@
flex-direction: row;
align-items: center;
white-space: nowrap;
font-weight: 400;
padding: 2px 10px;
cursor: pointer;
touch-action: none;
* {
display: flex;
}
&:hover {
background: rgba(90, 90, 90, 0.1);
}
.menu-name {
flex-grow: 1;
margin-right: 20px;
}
.SubmenuTriangleIcon {

View File

@ -1,10 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
export type MenuOptionProps = {
id: string,
name: string,
onClick?: (id: string) => void,
onClick: (id: string) => void,
}

View File

@ -4,11 +4,11 @@ import React from 'react'
import SubmenuTriangleIcon from '../icons/submenuTriangle'
import {MenuOptionProps} from './menuItem'
import './subMenuOption.scss'
type SubMenuOptionProps = MenuOptionProps & {
type SubMenuOptionProps = {
id: string,
name: string,
position?: 'bottom' | 'top'
icon?: React.ReactNode
}

View File

@ -12,7 +12,8 @@ type SwitchOptionProps = MenuOptionProps & {
}
export default class SwitchOption extends React.PureComponent<SwitchOptionProps> {
private handleOnClick = (): void => {
private handleOnClick = (e: React.MouseEvent): void => {
e.target.dispatchEvent(new Event('menuItemClicked'))
this.props.onClick(this.props.id)
}

View File

@ -10,7 +10,8 @@ type TextOptionProps = MenuOptionProps & {
}
export default class TextOption extends React.PureComponent<TextOptionProps> {
private handleOnClick = (): void => {
private handleOnClick = (e: React.MouseEvent): void => {
e.target.dispatchEvent(new Event('menuItemClicked'))
this.props.onClick(this.props.id)
}

View File

@ -31,12 +31,14 @@ export default class MenuWrapper extends React.PureComponent<Props, State> {
this.node = React.createRef()
}
public componentDidMount() {
public componentDidMount(): void {
document.addEventListener('menuItemClicked', this.close, true)
document.addEventListener('click', this.closeOnBlur, true)
document.addEventListener('keyup', this.keyboardClose, true)
}
public componentWillUnmount() {
public componentWillUnmount(): void {
document.removeEventListener('menuItemClicked', this.close, true)
document.removeEventListener('click', this.closeOnBlur, true)
document.removeEventListener('keyup', this.keyboardClose, true)
}
@ -59,13 +61,13 @@ export default class MenuWrapper extends React.PureComponent<Props, State> {
this.close()
}
public close = () => {
public close = (): void => {
if (this.state.open) {
this.setState({open: false})
}
}
toggle = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
private toggle = (e: React.MouseEvent<HTMLDivElement, MouseEvent>): void => {
/**
* This is only here so that we can toggle the menus in the sidebar, because the default behavior of the mobile
* version (ie the one that uses a modal) needs propagation to close the modal after selecting something
@ -80,7 +82,7 @@ export default class MenuWrapper extends React.PureComponent<Props, State> {
this.setState({open: newState})
}
public render() {
public render(): JSX.Element {
const {children} = this.props
return (

View File

@ -25,18 +25,14 @@ type State = {
export default class PropertyMenu extends React.PureComponent<Props, State> {
private nameTextbox = React.createRef<HTMLInputElement>()
public shouldComponentUpdate(): boolean {
return true
}
constructor(props: Props) {
super(props)
this.state = {name: this.props.propertyName}
}
public componentDidMount(): void {
this.nameTextbox.current.focus()
document.execCommand('selectAll', false, null)
this.nameTextbox.current?.focus()
document.execCommand('selectAll', false, undefined)
}
private typeDisplayName(type: PropertyType): string {

View File

@ -18,12 +18,12 @@ import './valueSelector.scss'
type Props = {
options: IPropertyOption[]
value: IPropertyOption;
emptyValue: string;
onCreate?: (value: string) => void
onChange?: (value: string) => void
onChangeColor?: (option: IPropertyOption, color: string) => void
onDeleteOption?: (option: IPropertyOption) => void
value?: IPropertyOption
emptyValue: string
onCreate: (value: string) => void
onChange: (value: string) => void
onChangeColor: (option: IPropertyOption, color: string) => void
onDeleteOption: (option: IPropertyOption) => void
intl: IntlShape
}

View File

@ -6,7 +6,7 @@
"esModuleInterop": true,
"noImplicitAny": true,
"strict": true,
"strictNullChecks": false,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"allowJs": true,
@ -26,5 +26,9 @@
"."
],
"exclude": [
]
".git",
"**/node_modules/*",
"dist",
"pack"
]
}