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>/**" "<node_internals>/**"
], ],
"type": "pwa-node" "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 all: server
@ -13,10 +13,15 @@ prebuild:
server: server:
cd server; go build -o ../bin/octoserver ./main cd server; go build -o ../bin/octoserver ./main
server-linux: server-mac:
cd server; env GOOS=linux GOARCH=amd64 go build -o ../bin/octoserver ./main 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 cd server; env GOOS=windows GOARCH=amd64 go build -o ../bin/octoserver.exe ./main
generate: generate:
@ -43,18 +48,18 @@ watch-server:
webapp: webapp:
cd webapp; npm run pack cd webapp; npm run pack
mac-app: server webapp mac-app: server-mac webapp
rm -rf mac/resources/bin rm -rf mac/resources/bin
rm -rf mac/resources/pack rm -rf mac/resources/pack
mkdir -p mac/resources mkdir -p mac/resources/bin
cp -R bin mac/resources/bin cp bin/mac/octoserver mac/resources/bin/octoserver
cp -R webapp/pack mac/resources/pack cp -R webapp/pack mac/resources/pack
mkdir -p mac/temp mkdir -p mac/temp
xcodebuild archive -workspace mac/Tasks.xcworkspace -scheme Tasks -archivePath mac/temp/tasks.xcarchive 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 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 cd win; make build
mkdir -p win/dist/bin mkdir -p win/dist/bin
cp -R bin/octoserver.exe win/dist/bin cp -R bin/octoserver.exe win/dist/bin
@ -67,7 +72,7 @@ linux-app: server-linux webapp
rm -rf linux/temp rm -rf linux/temp
mkdir -p linux/temp/tasks-app/webapp mkdir -p linux/temp/tasks-app/webapp
mkdir -p linux/dist 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 config.json linux/temp/tasks-app/
cp -R webapp/pack linux/temp/tasks-app/webapp/pack cp -R webapp/pack linux/temp/tasks-app/webapp/pack
cd linux; make build cd linux; make build
@ -81,6 +86,8 @@ clean:
rm -rf webapp/pack rm -rf webapp/pack
rm -rf mac/temp rm -rf mac/temp
rm -rf mac/dist rm -rf mac/dist
rm -rf linux/dist
rm -rf win/dist
cleanall: clean cleanall: clean
rm -rf webapp/node_modules rm -rf webapp/node_modules

View File

@ -3,10 +3,6 @@
## Building the server ## Building the server
``` ```
cd webapp
npm install
npm run packdev
cd ..
make prebuild make prebuild
make make
``` ```
@ -15,8 +11,9 @@ Currently tested with:
* Go 1.15.2 * Go 1.15.2
* MacOS Catalina (10.15.6) * MacOS Catalina (10.15.6)
* Ubuntu 18.04 * 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 * In config.json
* Set dbtype to "postgres" * Set dbtype to "postgres"
* Set dbconfig to the connection string (which you can copy from dbconfig_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 ## Running and testing the server
To start the server: To start the server, run `./bin/octoserver`
```
./bin/octoserver
```
Server settings are in config.json. Server settings are in config.json.
Open a browser to [http://localhost:8000](http://localhost:8000) to start. Open a browser to [http://localhost:8000](http://localhost:8000) to start.
## Building and running the macOS app ## Building and running standalone desktop apps
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.
First build the server using the steps above, then run: You can build standalone apps that package the server to run locally against SQLite:
```
make mac
```
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" "log"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -155,7 +156,21 @@ func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
blockID := vars["blockID"] 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 { if err != nil {
log.Printf(`ERROR: %v`, r) log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, nil) errorResponse(w, http.StatusInternalServerError, nil)
@ -163,7 +178,7 @@ func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) {
return 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) json, err := json.Marshal(blocks)
if err != nil { if err != nil {
log.Printf(`ERROR json.Marshal: %v`, r) log.Printf(`ERROR json.Marshal: %v`, r)
@ -302,7 +317,7 @@ func errorResponse(w http.ResponseWriter, code int, message map[string]string) {
data = []byte("{}") data = []byte("{}")
} }
w.WriteHeader(code) w.WriteHeader(code)
fmt.Fprint(w, data) w.Write(data)
} }
func addUserID(rw http.ResponseWriter, req *http.Request, next http.Handler) { 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 return err
} }
a.wsServer.BroadcastBlockChange(block)
go a.webhook.NotifyUpdate(block) go a.webhook.NotifyUpdate(block)
} }
a.wsServer.BroadcastBlockChangeToWebsocketClients(blockIDsToNotify)
return nil return nil
} }
func (a *App) GetSubTree(blockID string) ([]model.Block, error) { func (a *App) GetSubTree(blockID string, levels int) ([]model.Block, error) {
return a.store.GetSubTree(blockID) // 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) { func (a *App) GetAllBlocks() ([]model.Block, error) {
@ -76,7 +79,7 @@ func (a *App) DeleteBlock(blockID string) error {
return err return err
} }
a.wsServer.BroadcastBlockChangeToWebsocketClients(blockIDsToNotify) a.wsServer.BroadcastBlockDelete(blockID, parentID)
return nil return nil
} }

View File

@ -1,13 +1,13 @@
package app package app
import ( import (
"crypto/rand"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/mattermost/mattermost-octo-tasks/server/utils"
) )
func (a *App) SaveFile(reader io.Reader, filename string) (string, error) { 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" fileExtension = ".jpg"
} }
createdFilename := fmt.Sprintf(`%s%s`, createGUID(), fileExtension) createdFilename := fmt.Sprintf(`%s%s`, utils.CreateGUID(), fileExtension)
_, appErr := a.filesBackend.WriteFile(reader, createdFilename) _, appErr := a.filesBackend.WriteFile(reader, createdFilename)
if appErr != nil { if appErr != nil {
@ -32,15 +32,3 @@ func (a *App) GetFilePath(filename string) string {
return filepath.Join(folderPath, filename) 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 package model
import (
"encoding/json"
"io"
)
// Block is the basic data unit. // Block is the basic data unit.
type Block struct { type Block struct {
ID string `json:"id"` ID string `json:"id"`
@ -12,3 +17,9 @@ type Block struct {
UpdateAt int64 `json:"updateAt"` UpdateAt int64 `json:"updateAt"`
DeleteAt int64 `json:"deleteAt"` 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 { func (s *Server) Shutdown() error {
if err := s.webServer.Shutdown(); err != nil {
return err
}
return s.store.Shutdown() 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) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetParentID", reflect.TypeOf((*MockStore)(nil).GetParentID), arg0)
} }
// GetSubTree mocks base method // GetSubTree2 mocks base method
func (m *MockStore) GetSubTree(arg0 string) ([]model.Block, error) { func (m *MockStore) GetSubTree2(arg0 string) ([]model.Block, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSubTree", arg0) ret := m.ctrl.Call(m, "GetSubTree2", arg0)
ret0, _ := ret[0].([]model.Block) ret0, _ := ret[0].([]model.Block)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
// GetSubTree indicates an expected call of GetSubTree // GetSubTree2 indicates an expected call of GetSubTree2
func (mr *MockStoreMockRecorder) GetSubTree(arg0 interface{}) *gomock.Call { func (mr *MockStoreMockRecorder) GetSubTree2(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper() 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 // GetSystemSettings mocks base method

View File

@ -15,7 +15,10 @@ import (
func (s *SQLStore) latestsBlocksSubquery() sq.SelectBuilder { func (s *SQLStore) latestsBlocksSubquery() sq.SelectBuilder {
internalQuery := sq.Select("*", "ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn").From("blocks") 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) { 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", "COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at"). "delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest"). FromSelect(s.latestsBlocksSubquery(), "latest").
Where(sq.Eq{"delete_at": 0}).
Where(sq.Eq{"parent_id": parentID}). Where(sq.Eq{"parent_id": parentID}).
Where(sq.Eq{"type": blockType}) Where(sq.Eq{"type": blockType})
@ -44,7 +46,6 @@ func (s *SQLStore) GetBlocksWithParent(parentID string) ([]model.Block, error) {
"COALESCE(\"fields\", '{}')", "create_at", "update_at", "COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at"). "delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest"). FromSelect(s.latestsBlocksSubquery(), "latest").
Where(sq.Eq{"delete_at": 0}).
Where(sq.Eq{"parent_id": parentID}) Where(sq.Eq{"parent_id": parentID})
rows, err := query.Query() rows, err := query.Query()
@ -63,7 +64,6 @@ func (s *SQLStore) GetBlocksWithType(blockType string) ([]model.Block, error) {
"COALESCE(\"fields\", '{}')", "create_at", "update_at", "COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at"). "delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest"). FromSelect(s.latestsBlocksSubquery(), "latest").
Where(sq.Eq{"delete_at": 0}).
Where(sq.Eq{"type": blockType}) Where(sq.Eq{"type": blockType})
rows, err := query.Query() rows, err := query.Query()
@ -76,13 +76,13 @@ func (s *SQLStore) GetBlocksWithType(blockType string) ([]model.Block, error) {
return blocksFromRows(rows) 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(). query := s.getQueryBuilder().
Select("id", "parent_id", "schema", "type", "title", Select("id", "parent_id", "schema", "type", "title",
"COALESCE(\"fields\", '{}')", "create_at", "update_at", "COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at"). "delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest"). FromSelect(s.latestsBlocksSubquery(), "latest").
Where(sq.Eq{"delete_at": 0}).
Where(sq.Or{sq.Eq{"id": blockID}, sq.Eq{"parent_id": blockID}}) Where(sq.Or{sq.Eq{"id": blockID}, sq.Eq{"parent_id": blockID}})
rows, err := query.Query() rows, err := query.Query()
@ -95,13 +95,44 @@ func (s *SQLStore) GetSubTree(blockID string) ([]model.Block, error) {
return blocksFromRows(rows) 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) { func (s *SQLStore) GetAllBlocks() ([]model.Block, error) {
query := s.getQueryBuilder(). query := s.getQueryBuilder().
Select("id", "parent_id", "schema", "type", "title", Select("id", "parent_id", "schema", "type", "title",
"COALESCE(\"fields\", '{}')", "create_at", "update_at", "COALESCE(\"fields\", '{}')", "create_at", "update_at",
"delete_at"). "delete_at").
FromSelect(s.latestsBlocksSubquery(), "latest"). FromSelect(s.latestsBlocksSubquery(), "latest")
Where(sq.Eq{"delete_at": 0})
rows, err := query.Query() rows, err := query.Query()
if err != nil { if err != nil {
@ -156,7 +187,6 @@ func blocksFromRows(rows *sql.Rows) ([]model.Block, error) {
func (s *SQLStore) GetParentID(blockID string) (string, error) { func (s *SQLStore) GetParentID(blockID string) (string, error) {
query := s.getQueryBuilder().Select("parent_id"). query := s.getQueryBuilder().Select("parent_id").
FromSelect(s.latestsBlocksSubquery(), "latest"). FromSelect(s.latestsBlocksSubquery(), "latest").
Where(sq.Eq{"delete_at": 0}).
Where(sq.Eq{"id": blockID}) Where(sq.Eq{"id": blockID})
row := query.QueryRow() row := query.QueryRow()

View File

@ -36,3 +36,109 @@ func TestInsertBlock(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Empty(t, blocks) 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" "os"
"testing" "testing"
"github.com/mattermost/mattermost-octo-tasks/server/model"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -28,3 +29,27 @@ func SetupTests(t *testing.T) (*SQLStore, func()) {
return store, tearDown 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) GetBlocksWithParentAndType(parentID string, blockType string) ([]model.Block, error)
GetBlocksWithParent(parentID string) ([]model.Block, error) GetBlocksWithParent(parentID string) ([]model.Block, error)
GetBlocksWithType(blockType 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) GetAllBlocks() ([]model.Block, error)
GetParentID(blockID string) (string, error) GetParentID(blockID string) (string, error)
InsertBlock(block model.Block) 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. // Server is the structure responsible for managing our http web server.
type Server struct { type Server struct {
router *mux.Router http.Server
rootPath string rootPath string
port int port int
ssl bool ssl bool
@ -31,7 +32,10 @@ func NewServer(rootPath string, port int, ssl bool) *Server {
r := mux.NewRouter() r := mux.NewRouter()
ws := &Server{ ws := &Server{
router: r, Server: http.Server{
Addr: fmt.Sprintf(`:%d`, port),
Handler: r,
},
rootPath: rootPath, rootPath: rootPath,
port: port, port: port,
ssl: ssl, ssl: ssl,
@ -40,14 +44,18 @@ func NewServer(rootPath string, port int, ssl bool) *Server {
return ws 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. // AddRoutes allows services to register themself in the webserver router and provide new endpoints.
func (ws *Server) AddRoutes(rs RoutedService) { func (ws *Server) AddRoutes(rs RoutedService) {
rs.RegisterRoutes(ws.router) rs.RegisterRoutes(ws.Router())
} }
func (ws *Server) registerRoutes() { func (ws *Server) registerRoutes() {
ws.router.PathPrefix("/static").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(ws.rootPath, "static"))))) 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("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeFile(w, r, path.Join(ws.rootPath, "index.html")) 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. // Start runs the web server and start listening for charsetnnections.
func (ws *Server) Start() error { func (ws *Server) Start() error {
ws.registerRoutes() ws.registerRoutes()
http.Handle("/", ws.router)
urlPort := fmt.Sprintf(`:%d`, ws.port)
isSSL := ws.ssl && fileExists("./cert/cert.pem") && fileExists("./cert/key.pem") isSSL := ws.ssl && fileExists("./cert/cert.pem") && fileExists("./cert/key.pem")
if isSSL { if isSSL {
log.Println("https server started on ", urlPort) log.Printf("https server started on :%d\n", ws.port)
err := http.ListenAndServeTLS(urlPort, "./cert/cert.pem", "./cert/key.pem", nil) err := ws.ListenAndServeTLS("./cert/cert.pem", "./cert/key.pem")
if err != nil { if err != nil {
return err return err
} }
@ -71,8 +76,8 @@ func (ws *Server) Start() error {
return nil return nil
} }
log.Println("http server started on ", urlPort) log.Printf("http server started on :%d\n", ws.port)
err := http.ListenAndServe(urlPort, nil) err := ws.ListenAndServe()
if err != nil { if err != nil {
return err return err
} }
@ -80,6 +85,10 @@ func (ws *Server) Start() error {
return nil return nil
} }
func (ws *Server) Shutdown() error {
return ws.Close()
}
// fileExists returns true if a file exists at the path. // fileExists returns true if a file exists at the path.
func fileExists(path string) bool { func fileExists(path string) bool {
_, err := os.Stat(path) _, err := os.Stat(path)

View File

@ -5,9 +5,11 @@ import (
"log" "log"
"net/http" "net/http"
"sync" "sync"
"time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/mattermost/mattermost-octo-tasks/server/model"
) )
// RegisterRoutes registers routes. // RegisterRoutes registers routes.
@ -98,10 +100,10 @@ func NewServer() *Server {
} }
} }
// WebsocketMsg is sent on block changes. // UpdateMsg is sent on block updates
type WebsocketMsg struct { type UpdateMsg struct {
Action string `json:"action"` Action string `json:"action"`
BlockID string `json:"blockId"` Block model.Block `json:"block"`
} }
// WebsocketCommand is an incoming command from the client. // 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. // BroadcastBlockDelete broadcasts delete messages to clients
func (ws *Server) BroadcastBlockChangeToWebsocketClients(blockIDs []string) { func (ws *Server) BroadcastBlockDelete(blockID string, parentID string) {
for _, blockID := range blockIDs { 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) listeners := ws.GetListeners(blockID)
log.Printf("%d listener(s) for blockID: %s", len(listeners), blockID) log.Printf("%d listener(s) for blockID: %s", len(listeners), blockID)
if listeners != nil { if listeners != nil {
message := WebsocketMsg{ message := UpdateMsg{
Action: "UPDATE_BLOCK", Action: "UPDATE_BLOCK",
BlockID: blockID, Block: block,
} }
for _, listener := range listeners { for _, listener := range listeners {

View File

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

View File

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

View File

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

View File

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

View File

@ -57,6 +57,7 @@ class MutableBoard extends MutableBlock {
super(block) super(block)
this.type = 'board' this.type = 'board'
this.icon = block.fields?.icon || ''
if (block.fields?.cardProperties) { if (block.fields?.cardProperties) {
// Deep clone of card properties and their options // Deep clone of card properties and their options
this.cardProperties = block.fields.cardProperties.map((o: IPropertyTemplate) => { 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 { interface BoardView extends IBlock {
readonly viewType: IViewType readonly viewType: IViewType
readonly groupById: string readonly groupById?: string
readonly sortOptions: readonly ISortOption[] readonly sortOptions: readonly ISortOption[]
readonly visiblePropertyIds: readonly string[] readonly visiblePropertyIds: readonly string[]
readonly visibleOptionIds: readonly string[] readonly visibleOptionIds: readonly string[]
readonly hiddenOptionIds: readonly string[] readonly hiddenOptionIds: readonly string[]
readonly filter: FilterGroup | undefined readonly filter: FilterGroup
readonly cardOrder: readonly string[] readonly cardOrder: readonly string[]
readonly columnWidths: Readonly<Record<string, number>> readonly columnWidths: Readonly<Record<string, number>>
} }
class MutableBoardView extends MutableBlock { class MutableBoardView extends MutableBlock implements BoardView {
get viewType(): IViewType { get viewType(): IViewType {
return this.fields.viewType return this.fields.viewType
} }
@ -63,10 +63,10 @@ class MutableBoardView extends MutableBlock {
this.fields.hiddenOptionIds = value this.fields.hiddenOptionIds = value
} }
get filter(): FilterGroup | undefined { get filter(): FilterGroup {
return this.fields.filter return this.fields.filter
} }
set filter(value: FilterGroup | undefined) { set filter(value: FilterGroup) {
this.fields.filter = value this.fields.filter = value
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react' import React from 'react'
import {FormattedMessage} from 'react-intl'
import {Card} from '../blocks/card'
import {BoardTree} from '../viewModel/boardTree'
import mutator from '../mutator' 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 DeleteIcon from '../widgets/icons/delete'
import Menu from '../widgets/menu'
import CardDetail from './cardDetail' import CardDetail from './cardDetail'
import Dialog from './dialog' import Dialog from './dialog'
type Props = { type Props = {
boardTree: BoardTree boardTree: BoardTree
card: Card cardId: string
onClose: () => void onClose: () => void
showCard: (cardId?: string) => void
} }
class CardDialog extends React.Component<Props> { type State = {
render() { 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 = ( const menu = (
<Menu position='left'> <Menu position='left'>
<Menu.Text <Menu.Text
@ -26,10 +81,22 @@ class CardDialog extends React.Component<Props> {
icon={<DeleteIcon/>} icon={<DeleteIcon/>}
name='Delete' name='Delete'
onClick={async () => { 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() this.props.onClose()
}} }}
/> />
{(cardTree && !cardTree.card.isTemplate) &&
<Menu.Text
id='makeTemplate'
name='New template from card'
onClick={this.makeTemplate}
/>
}
</Menu> </Menu>
) )
return ( return (
@ -37,13 +104,49 @@ class CardDialog extends React.Component<Props> {
onClose={this.props.onClose} onClose={this.props.onClose}
toolsMenu={menu} toolsMenu={menu}
> >
<CardDetail {(cardTree?.card.isTemplate) &&
boardTree={this.props.boardTree} <div className='banner'>
cardId={this.props.card.id} <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> </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} export {CardDialog}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import {FilterGroup} from './filterGroup'
import octoClient from './octoClient' import octoClient from './octoClient'
import undoManager from './undomanager' import undoManager from './undomanager'
import {Utils} from './utils' import {Utils} from './utils'
import {OctoUtils} from './octoUtils'
// //
// The Mutator is used to make all changes to server state // The Mutator is used to make all changes to server state
@ -19,10 +20,10 @@ import {Utils} from './utils'
class Mutator { class Mutator {
private undoGroupId?: string private undoGroupId?: string
private beginUndoGroup(): string { private beginUndoGroup(): string | undefined {
if (this.undoGroupId) { if (this.undoGroupId) {
Utils.assertFailure('UndoManager does not support nested groups') Utils.assertFailure('UndoManager does not support nested groups')
return return undefined
} }
this.undoGroupId = Utils.createGuid() this.undoGroupId = Utils.createGuid()
return this.undoGroupId return this.undoGroupId
@ -43,7 +44,9 @@ class Mutator {
} catch (err) { } catch (err) {
Utils.assertFailure(`ERROR: ${err?.toString?.()}`) Utils.assertFailure(`ERROR: ${err?.toString?.()}`)
} }
this.endUndoGroup(groupId) if (groupId) {
this.endUndoGroup(groupId)
}
} }
async updateBlock(newBlock: IBlock, oldBlock: IBlock, description: string): Promise<void> { async updateBlock(newBlock: IBlock, oldBlock: IBlock, description: string): Promise<void> {
@ -95,9 +98,11 @@ class Mutator {
}, },
async () => { async () => {
await beforeUndo?.() await beforeUndo?.()
const awaits = []
for (const block of blocks) { for (const block of blocks) {
await octoClient.deleteBlock(block.id) awaits.push(octoClient.deleteBlock(block.id))
} }
await Promise.all(awaits)
}, },
description, description,
this.undoGroupId, this.undoGroupId,
@ -105,9 +110,7 @@ class Mutator {
} }
async deleteBlock(block: IBlock, description?: string, beforeRedo?: () => Promise<void>, afterUndo?: () => Promise<void>) { async deleteBlock(block: IBlock, description?: string, beforeRedo?: () => Promise<void>, afterUndo?: () => Promise<void>) {
if (!description) { const actualDescription = description || `delete ${block.type}`
description = `delete ${block.type}`
}
await undoManager.perform( await undoManager.perform(
async () => { async () => {
@ -118,7 +121,7 @@ class Mutator {
await octoClient.insertBlock(block) await octoClient.insertBlock(block)
await afterUndo?.() await afterUndo?.()
}, },
description, actualDescription,
this.undoGroupId, this.undoGroupId,
) )
} }
@ -144,6 +147,10 @@ class Mutator {
newBlock = board newBlock = board
break break
} }
default: {
Utils.assertFailure(`changeIcon: Invalid block type: ${block.type}`)
return
}
} }
await this.updateBlock(newBlock, block, description) await this.updateBlock(newBlock, block, description)
@ -159,24 +166,23 @@ class Mutator {
async insertPropertyTemplate(boardTree: BoardTree, index = -1, template?: IPropertyTemplate) { async insertPropertyTemplate(boardTree: BoardTree, index = -1, template?: IPropertyTemplate) {
const {board, activeView} = boardTree const {board, activeView} = boardTree
if (!activeView) {
if (index < 0) { Utils.assertFailure('insertPropertyTemplate: no activeView')
index = board.cardProperties.length return
} }
if (!template) { const newTemplate = template || {
template = { id: Utils.createGuid(),
id: Utils.createGuid(), name: 'New Property',
name: 'New Property', type: 'text',
type: 'text', options: [],
options: [],
}
} }
const oldBlocks: IBlock[] = [board] const oldBlocks: IBlock[] = [board]
const newBoard = new MutableBoard(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] const changedBlocks: IBlock[] = [newBoard]
let description = 'add property' let description = 'add property'
@ -185,7 +191,7 @@ class Mutator {
oldBlocks.push(activeView) oldBlocks.push(activeView)
const newActiveView = new MutableBoardView(activeView) const newActiveView = new MutableBoardView(activeView)
newActiveView.visiblePropertyIds.push(template.id) newActiveView.visiblePropertyIds.push(newTemplate.id)
changedBlocks.push(newActiveView) changedBlocks.push(newActiveView)
description = 'add column' description = 'add column'
@ -196,6 +202,10 @@ class Mutator {
async duplicatePropertyTemplate(boardTree: BoardTree, propertyId: string) { async duplicatePropertyTemplate(boardTree: BoardTree, propertyId: string) {
const {board, activeView} = boardTree const {board, activeView} = boardTree
if (!activeView) {
Utils.assertFailure('duplicatePropertyTemplate: no activeView')
return
}
const oldBlocks: IBlock[] = [board] const oldBlocks: IBlock[] = [board]
@ -296,7 +306,7 @@ class Mutator {
Utils.assert(board.cardProperties.includes(template)) Utils.assert(board.cardProperties.includes(template))
const newBoard = new MutableBoard(board) 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) newTemplate.options.push(option)
await this.updateBlock(newBoard, board, description) await this.updateBlock(newBoard, board, description)
@ -306,7 +316,7 @@ class Mutator {
const {board} = boardTree const {board} = boardTree
const newBoard = new MutableBoard(board) 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) newTemplate.options = newTemplate.options.filter((o) => o.id !== option.id)
await this.updateBlock(newBoard, board, 'delete option') await this.updateBlock(newBoard, board, 'delete option')
@ -317,20 +327,20 @@ class Mutator {
Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`) Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`)
const newBoard = new MutableBoard(board) 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]) newTemplate.options.splice(destIndex, 0, newTemplate.options.splice(srcIndex, 1)[0])
await this.updateBlock(newBoard, board, 'reorder options') await this.updateBlock(newBoard, board, 'reorder options')
} }
async changePropertyOptionValue(boardTree: BoardTree, propertyTemplate: IPropertyTemplate, option: IPropertyOption, value: string) { async changePropertyOptionValue(boardTree: BoardTree, propertyTemplate: IPropertyTemplate, option: IPropertyOption, value: string) {
const {board, cards} = boardTree const {board} = boardTree
const oldBlocks: IBlock[] = [board] const oldBlocks: IBlock[] = [board]
const newBoard = new MutableBoard(board) 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)!
const newOption = newTemplate.options.find((o) => o.id === option.id) const newOption = newTemplate.options.find((o) => o.id === option.id)!
newOption.value = value newOption.value = value
const changedBlocks: IBlock[] = [newBoard] const changedBlocks: IBlock[] = [newBoard]
@ -341,15 +351,19 @@ class Mutator {
async changePropertyOptionColor(board: Board, template: IPropertyTemplate, option: IPropertyOption, color: string) { async changePropertyOptionColor(board: Board, template: IPropertyTemplate, option: IPropertyOption, color: string) {
const newBoard = new MutableBoard(board) 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)!
const newOption = newTemplate.options.find((o) => o.id === option.id) const newOption = newTemplate.options.find((o) => o.id === option.id)!
newOption.color = color newOption.color = color
await this.updateBlock(newBoard, board, 'change option color') await this.updateBlock(newBoard, board, 'change option color')
} }
async changePropertyValue(card: Card, propertyId: string, value?: string, description = 'change property') { async changePropertyValue(card: Card, propertyId: string, value?: string, description = 'change property') {
const newCard = new MutableCard(card) 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) await this.updateBlock(newCard, card, description)
} }
@ -357,7 +371,7 @@ class Mutator {
const {board} = boardTree const {board} = boardTree
const newBoard = new MutableBoard(board) 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 newTemplate.type = type
const oldBlocks: IBlock[] = [board] const oldBlocks: IBlock[] = [board]
@ -369,7 +383,11 @@ class Mutator {
if (oldValue) { if (oldValue) {
const newValue = propertyTemplate.options.find((o) => o.id === oldValue)?.value const newValue = propertyTemplate.options.find((o) => o.id === oldValue)?.value
const newCard = new MutableCard(card) 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) newBlocks.push(newCard)
oldBlocks.push(card) oldBlocks.push(card)
} }
@ -381,7 +399,11 @@ class Mutator {
if (oldValue) { if (oldValue) {
const newValue = propertyTemplate.options.find((o) => o.value === oldValue)?.id const newValue = propertyTemplate.options.find((o) => o.value === oldValue)?.id
const newCard = new MutableCard(card) 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) newBlocks.push(newCard)
oldBlocks.push(card) oldBlocks.push(card)
} }
@ -399,7 +421,7 @@ class Mutator {
await this.updateBlock(newView, view, 'sort') 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) const newView = new MutableBoardView(view)
newView.filter = filter newView.filter = filter
await this.updateBlock(newView, view, 'filter') await this.updateBlock(newView, view, 'filter')
@ -460,6 +482,46 @@ class Mutator {
await this.updateBlock(newView, view, description) 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 // Other methods
// Not a mutator, but convenient to put here since Mutator wraps OctoClient // 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}`) Utils.log(`OctoClient serverUrl: ${this.serverUrl}`)
} }
async getSubtree(rootId?: string): Promise<IBlock[]> { async getSubtree(rootId?: string, levels = 2): Promise<IBlock[]> {
const path = `/api/v1/blocks/${rootId}/subtree` const path = `/api/v1/blocks/${rootId}/subtree?l=${levels}`
const response = await fetch(this.serverUrl + path) const response = await fetch(this.serverUrl + path)
const blocks = (await response.json() || []) as IMutableBlock[] const blocks = (await response.json() || []) as IMutableBlock[]
this.fixBlocks(blocks) this.fixBlocks(blocks)
@ -36,7 +36,7 @@ class OctoClient {
Utils.log(`\t ${block.type}, ${block.id}`) Utils.log(`\t ${block.type}, ${block.id}`)
}) })
const body = JSON.stringify(blocks) const body = JSON.stringify(blocks)
return await fetch(this.serverUrl + '/api/v1/blocks/import', { return fetch(this.serverUrl + '/api/v1/blocks/import', {
method: 'POST', method: 'POST',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -68,35 +68,22 @@ class OctoClient {
return blocks return blocks
} }
// TODO: Remove this fixup code
fixBlocks(blocks: IMutableBlock[]): void { fixBlocks(blocks: IMutableBlock[]): void {
if (!blocks) { if (!blocks) {
return return
} }
// TODO
for (const block of blocks) { for (const block of blocks) {
if (!block.fields) { if (!block.fields) {
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> { async updateBlock(block: IMutableBlock): Promise<Response> {
block.updateAt = Date.now() block.updateAt = Date.now()
return await this.insertBlocks([block]) return this.insertBlocks([block])
} }
async updateBlocks(blocks: IMutableBlock[]): Promise<Response> { async updateBlocks(blocks: IMutableBlock[]): Promise<Response> {
@ -104,12 +91,12 @@ class OctoClient {
blocks.forEach((block) => { blocks.forEach((block) => {
block.updateAt = now block.updateAt = now
}) })
return await this.insertBlocks(blocks) return this.insertBlocks(blocks)
} }
async deleteBlock(blockId: string): Promise<Response> { async deleteBlock(blockId: string): Promise<Response> {
Utils.log(`deleteBlock: ${blockId}`) 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', method: 'DELETE',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -125,10 +112,10 @@ class OctoClient {
async insertBlocks(blocks: IBlock[]): Promise<Response> { async insertBlocks(blocks: IBlock[]): Promise<Response> {
Utils.log(`insertBlocks: ${blocks.length} blocks(s)`) Utils.log(`insertBlocks: ${blocks.length} blocks(s)`)
blocks.forEach((block) => { 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) const body = JSON.stringify(blocks)
return await fetch(this.serverUrl + '/api/v1/blocks', { return fetch(this.serverUrl + '/api/v1/blocks', {
method: 'POST', method: 'POST',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',

View File

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

View File

@ -1,21 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'
import {IBlock, MutableBlock} from './blocks/block' import {IBlock, MutableBlock} from './blocks/block'
import {IPropertyTemplate, MutableBoard} from './blocks/board' import {IPropertyTemplate, MutableBoard} from './blocks/board'
import {MutableBoardView} from './blocks/boardView' import {MutableBoardView} from './blocks/boardView'
import {MutableCard} from './blocks/card' import {MutableCard} from './blocks/card'
import {MutableCommentBlock} from './blocks/commentBlock' import {MutableCommentBlock} from './blocks/commentBlock'
import {MutableImageBlock} from './blocks/imageBlock'
import {MutableDividerBlock} from './blocks/dividerBlock' import {MutableDividerBlock} from './blocks/dividerBlock'
import {MutableImageBlock} from './blocks/imageBlock'
import {IOrderedBlock} from './blocks/orderedBlock' import {IOrderedBlock} from './blocks/orderedBlock'
import {MutableTextBlock} from './blocks/textBlock' import {MutableTextBlock} from './blocks/textBlock'
import {Utils} from './utils' import {Utils} from './utils'
class OctoUtils { class OctoUtils {
static propertyDisplayValue(block: IBlock, propertyValue: string | undefined, propertyTemplate: IPropertyTemplate): string | undefined { static propertyDisplayValue(block: IBlock, propertyValue: string | undefined, propertyTemplate: IPropertyTemplate): string | undefined {
let displayValue: string let displayValue: string | undefined
switch (propertyTemplate.type) { switch (propertyTemplate.type) {
case 'select': { case 'select': {
// The property value is the id of the template // The property value is the id of the template
@ -80,6 +78,42 @@ class OctoUtils {
static hydrateBlocks(blocks: IBlock[]): MutableBlock[] { static hydrateBlocks(blocks: IBlock[]): MutableBlock[] {
return blocks.map((block) => this.hydrateBlock(block)) 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} export {OctoUtils}

View File

@ -2,14 +2,14 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react' import React from 'react'
import {BoardView} from '../blocks/boardView' import {IBlock} from '../blocks/block'
import {MutableBoardTree} from '../viewModel/boardTree'
import {WorkspaceComponent} from '../components/workspaceComponent'
import {sendFlashMessage} from '../components/flashMessages' import {sendFlashMessage} from '../components/flashMessages'
import {WorkspaceComponent} from '../components/workspaceComponent'
import mutator from '../mutator' import mutator from '../mutator'
import {OctoListener} from '../octoListener' import {OctoListener} from '../octoListener'
import {Utils} from '../utils' import {Utils} from '../utils'
import {MutableWorkspaceTree} from '../viewModel/workspaceTree' import {BoardTree, MutableBoardTree} from '../viewModel/boardTree'
import {MutableWorkspaceTree, WorkspaceTree} from '../viewModel/workspaceTree'
type Props = { type Props = {
setLanguage: (lang: string) => void setLanguage: (lang: string) => void
@ -18,23 +18,18 @@ type Props = {
type State = { type State = {
boardId: string boardId: string
viewId: string viewId: string
workspaceTree: MutableWorkspaceTree workspaceTree: WorkspaceTree
boardTree?: MutableBoardTree boardTree?: BoardTree
} }
export default class BoardPage extends React.Component<Props, State> { export default class BoardPage extends React.Component<Props, State> {
view: BoardView
updateTitleTimeout: number
updatePropertyLabelTimeout: number
private workspaceListener = new OctoListener() private workspaceListener = new OctoListener()
constructor(props: Props) { constructor(props: Props) {
super(props) super(props)
const queryString = new URLSearchParams(window.location.search) const queryString = new URLSearchParams(window.location.search)
const boardId = queryString.get('id') const boardId = queryString.get('id') || ''
const viewId = queryString.get('v') const viewId = queryString.get('v') || ''
this.state = { this.state = {
boardId, 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) { if (e.target !== document.body) {
return 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) { private async sync(boardId: string = this.state.boardId, viewId: string | undefined = this.state.viewId) {
const {workspaceTree} = this.state
Utils.log(`sync start: ${boardId}`) Utils.log(`sync start: ${boardId}`)
const workspaceTree = new MutableWorkspaceTree()
await workspaceTree.sync() await workspaceTree.sync()
const boardIds = workspaceTree.boards.map((o) => o.id) const boardIds = workspaceTree.boards.map((o) => o.id)
this.setState({workspaceTree})
// Listen to boards plus all blocks at root (Empty string for parentId) // Listen to boards plus all blocks at root (Empty string for parentId)
this.workspaceListener.open(['', ...boardIds], async (blockId) => { this.workspaceListener.open(
Utils.log(`workspaceListener.onChanged: ${blockId}`) ['', ...boardIds],
this.sync() async (blocks) => {
}) Utils.log(`workspaceListener.onChanged: ${blocks.length}`)
this.incrementalUpdate(blocks)
},
() => {
Utils.log('workspaceListener.onReconnect')
this.sync()
},
)
if (boardId) { if (boardId) {
const boardTree = new MutableBoardTree(boardId) const boardTree = new MutableBoardTree(boardId)
await boardTree.sync() await boardTree.sync()
// Default to first view // Default to first view
if (!viewId) { boardTree.setActiveView(viewId || boardTree.views[0].id)
viewId = boardTree.views[0].id
}
boardTree.setActiveView(viewId)
// TODO: Handle error (viewId not found) // TODO: Handle error (viewId not found)
this.setState({ this.setState({
boardTree, boardTree,
boardId, boardId,
viewId: boardTree.activeView.id, viewId: boardTree.activeView!.id,
}) })
Utils.log(`sync complete: ${boardTree.board.id} (${boardTree.board.title})`) 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 // IPageController
showBoard(boardId: string): void { showBoard(boardId: string): void {
const {boardTree} = this.state 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 { showView(viewId: string, boardId: string = this.state.boardId): void {
if (this.state.boardId === boardId) { if (this.state.boardTree && this.state.boardId === boardId) {
this.state.boardTree.setActiveView(viewId) const newBoardTree = this.state.boardTree.mutableCopy()
this.setState({...this.state, viewId}) newBoardTree.setActiveView(viewId)
this.setState({boardTree: newBoardTree, viewId})
} else { } else {
this.attachToBoard(boardId, viewId) this.attachToBoard(boardId, viewId)
} }
@ -206,7 +224,13 @@ export default class BoardPage extends React.Component<Props, State> {
} }
setSearchText(text?: string): void { setSearchText(text?: string): void {
this.state.boardTree?.setSearchText(text) if (!this.state.boardTree) {
this.setState({...this.state, boardTree: 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 React from 'react'
import {Utils} from '../utils' import {Utils} from '../utils'
import Button from '../widgets/buttons/button' import Button from '../widgets/buttons/button'
import './loginPage.scss' import './loginPage.scss'
type Props = {} type Props = {
type State = {
username: string;
password: string;
} }
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 = { state = {
username: '', username: '',
password: '', password: '',
} }
handleLogin = () => { private handleLogin = (): void => {
Utils.log('Logging in') Utils.log('Logging in')
} }
@ -29,7 +28,7 @@ export default class LoginPage extends React.Component<Props, State> {
return ( return (
<div className='LoginPage'> <div className='LoginPage'>
<div className='username'> <div className='username'>
<label htmlFor='login-username'>Username</label> <label htmlFor='login-username'>{'Username'}</label>
<input <input
id='login-username' id='login-username'
value={this.state.username} value={this.state.username}
@ -37,7 +36,7 @@ export default class LoginPage extends React.Component<Props, State> {
/> />
</div> </div>
<div className='password'> <div className='password'>
<label htmlFor='login-username'>Password</label> <label htmlFor='login-username'>{'Password'}</label>
<input <input
id='login-password' id='login-password'
type='password' type='password'
@ -45,7 +44,7 @@ export default class LoginPage extends React.Component<Props, State> {
onChange={(e) => this.setState({password: e.target.value})} onChange={(e) => this.setState({password: e.target.value})}
/> />
</div> </div>
<Button onClick={this.handleLogin}>Login</Button> <Button onClick={this.handleLogin}>{'Login'}</Button>
</div> </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 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') await this.execute(command, 'undo')
this.index -= 1 this.index -= 1
command = this.commands[this.index] }
} while (this.index >= 0 && currentGroupId && currentGroupId === command.groupId)
if (this.onStateDidChange) { if (this.onStateDidChange) {
this.onStateDidChange() this.onStateDidChange()
@ -151,11 +157,17 @@ class UndoManager {
} }
const currentGroupId = command.groupId 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') await this.execute(command, 'redo')
this.index += 1 this.index += 1
command = this.commands[this.index + 1] }
} while (this.index < this.commands.length && currentGroupId && currentGroupId === command.groupId)
if (this.onStateDidChange) { if (this.onStateDidChange) {
this.onStateDidChange() this.onStateDidChange()

View File

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

View File

@ -1,32 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {Card} from '../blocks/card' import {IBlock, MutableBlock} from '../blocks/block'
import {Card, MutableCard} from '../blocks/card'
import {IOrderedBlock} from '../blocks/orderedBlock' import {IOrderedBlock} from '../blocks/orderedBlock'
import octoClient from '../octoClient' import octoClient from '../octoClient'
import {IBlock} from '../blocks/block'
import {OctoUtils} from '../octoUtils' import {OctoUtils} from '../octoUtils'
interface CardTree { interface CardTree {
readonly card: Card readonly card: Card
readonly comments: readonly IBlock[] readonly comments: readonly IBlock[]
readonly contents: readonly IOrderedBlock[] readonly contents: readonly IOrderedBlock[]
mutableCopy(): MutableCardTree
templateCopy(): MutableCardTree
} }
class MutableCardTree implements CardTree { class MutableCardTree implements CardTree {
card: Card card!: MutableCard
comments: IBlock[] comments: IBlock[] = []
contents: IOrderedBlock[] contents: IOrderedBlock[] = []
private rawBlocks: IBlock[] = []
constructor(private cardId: string) { constructor(private cardId: string) {
} }
async sync() { async sync(): Promise<void> {
const blocks = await octoClient.getSubtree(this.cardId) this.rawBlocks = await octoClient.getSubtree(this.cardId)
this.rebuild(OctoUtils.hydrateBlocks(blocks)) 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[]) { 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. this.comments = blocks.
filter((block) => block.type === 'comment'). 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[] 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) 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} export {MutableCardTree, CardTree}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'
export type MenuOptionProps = { export type MenuOptionProps = {
id: string, id: string,
name: 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 SubmenuTriangleIcon from '../icons/submenuTriangle'
import {MenuOptionProps} from './menuItem'
import './subMenuOption.scss' import './subMenuOption.scss'
type SubMenuOptionProps = MenuOptionProps & { type SubMenuOptionProps = {
id: string,
name: string,
position?: 'bottom' | 'top' position?: 'bottom' | 'top'
icon?: React.ReactNode icon?: React.ReactNode
} }

View File

@ -12,7 +12,8 @@ type SwitchOptionProps = MenuOptionProps & {
} }
export default class SwitchOption extends React.PureComponent<SwitchOptionProps> { 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) this.props.onClick(this.props.id)
} }

View File

@ -10,7 +10,8 @@ type TextOptionProps = MenuOptionProps & {
} }
export default class TextOption extends React.PureComponent<TextOptionProps> { 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) 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() this.node = React.createRef()
} }
public componentDidMount() { public componentDidMount(): void {
document.addEventListener('menuItemClicked', this.close, true)
document.addEventListener('click', this.closeOnBlur, true) document.addEventListener('click', this.closeOnBlur, true)
document.addEventListener('keyup', this.keyboardClose, 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('click', this.closeOnBlur, true)
document.removeEventListener('keyup', this.keyboardClose, true) document.removeEventListener('keyup', this.keyboardClose, true)
} }
@ -59,13 +61,13 @@ export default class MenuWrapper extends React.PureComponent<Props, State> {
this.close() this.close()
} }
public close = () => { public close = (): void => {
if (this.state.open) { if (this.state.open) {
this.setState({open: false}) 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 * 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 * 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}) this.setState({open: newState})
} }
public render() { public render(): JSX.Element {
const {children} = this.props const {children} = this.props
return ( return (

View File

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

View File

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

View File

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