You've already forked focalboard
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:
29
.vscode/launch.json
vendored
29
.vscode/launch.json
vendored
@ -20,6 +20,31 @@
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"type": "pwa-node"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Jest: run all tests",
|
||||
"program": "${workspaceRoot}/webapp/node_modules/jest/bin/jest.js",
|
||||
"cwd": "${workspaceRoot}/webapp",
|
||||
"args": [
|
||||
"--verbose",
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Jest: run current file",
|
||||
"program": "${workspaceRoot}/webapp/node_modules/jest/bin/jest.js",
|
||||
"cwd": "${workspaceRoot}/webapp",
|
||||
"args": [
|
||||
"${fileBasename}",
|
||||
"--verbose",
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
},
|
||||
]
|
||||
}
|
||||
|
27
Makefile
27
Makefile
@ -1,4 +1,4 @@
|
||||
.PHONY: prebuild clean cleanall server server-linux server-win64 generate watch-server webapp mac-app win-app linux-app
|
||||
.PHONY: prebuild clean cleanall server server-mac server-linux server-win generate watch-server webapp mac-app win-app linux-app
|
||||
|
||||
all: server
|
||||
|
||||
@ -13,10 +13,15 @@ prebuild:
|
||||
server:
|
||||
cd server; go build -o ../bin/octoserver ./main
|
||||
|
||||
server-linux:
|
||||
cd server; env GOOS=linux GOARCH=amd64 go build -o ../bin/octoserver ./main
|
||||
server-mac:
|
||||
mkdir -p bin/mac
|
||||
cd server; env GOOS=darwin GOARCH=amd64 go build -o ../bin/mac/octoserver ./main
|
||||
|
||||
server-win64:
|
||||
server-linux:
|
||||
mkdir -p bin/linux
|
||||
cd server; env GOOS=linux GOARCH=amd64 go build -o ../bin/linux/octoserver ./main
|
||||
|
||||
server-win:
|
||||
cd server; env GOOS=windows GOARCH=amd64 go build -o ../bin/octoserver.exe ./main
|
||||
|
||||
generate:
|
||||
@ -43,18 +48,18 @@ watch-server:
|
||||
webapp:
|
||||
cd webapp; npm run pack
|
||||
|
||||
mac-app: server webapp
|
||||
mac-app: server-mac webapp
|
||||
rm -rf mac/resources/bin
|
||||
rm -rf mac/resources/pack
|
||||
mkdir -p mac/resources
|
||||
cp -R bin mac/resources/bin
|
||||
mkdir -p mac/resources/bin
|
||||
cp bin/mac/octoserver mac/resources/bin/octoserver
|
||||
cp -R webapp/pack mac/resources/pack
|
||||
mkdir -p mac/temp
|
||||
xcodebuild archive -workspace mac/Tasks.xcworkspace -scheme Tasks -archivePath mac/temp/tasks.xcarchive
|
||||
xcodebuild -exportArchive -archivePath mac/temp/tasks.xcarchive -exportPath mac/dist -exportOptionsPlist mac/export.plist
|
||||
cd mac/dist; zip -r tasks.zip Tasks.app
|
||||
cd mac/dist; zip -r tasks-mac.zip Tasks.app
|
||||
|
||||
win-app: server-win64 webapp
|
||||
win-app: server-win webapp
|
||||
cd win; make build
|
||||
mkdir -p win/dist/bin
|
||||
cp -R bin/octoserver.exe win/dist/bin
|
||||
@ -67,7 +72,7 @@ linux-app: server-linux webapp
|
||||
rm -rf linux/temp
|
||||
mkdir -p linux/temp/tasks-app/webapp
|
||||
mkdir -p linux/dist
|
||||
cp -R bin/octoserver linux/temp/tasks-app/
|
||||
cp -R bin/linux/octoserver linux/temp/tasks-app/
|
||||
cp -R config.json linux/temp/tasks-app/
|
||||
cp -R webapp/pack linux/temp/tasks-app/webapp/pack
|
||||
cd linux; make build
|
||||
@ -81,6 +86,8 @@ clean:
|
||||
rm -rf webapp/pack
|
||||
rm -rf mac/temp
|
||||
rm -rf mac/dist
|
||||
rm -rf linux/dist
|
||||
rm -rf win/dist
|
||||
|
||||
cleanall: clean
|
||||
rm -rf webapp/node_modules
|
||||
|
34
README.md
34
README.md
@ -3,10 +3,6 @@
|
||||
## Building the server
|
||||
|
||||
```
|
||||
cd webapp
|
||||
npm install
|
||||
npm run packdev
|
||||
cd ..
|
||||
make prebuild
|
||||
make
|
||||
```
|
||||
@ -15,8 +11,9 @@ Currently tested with:
|
||||
* Go 1.15.2
|
||||
* MacOS Catalina (10.15.6)
|
||||
* Ubuntu 18.04
|
||||
* Windows 10
|
||||
|
||||
The server defaults to using sqlite as the store, but can be configured to use Postgres:
|
||||
The server defaults to using SQLite as the store, but can be configured to use Postgres:
|
||||
* In config.json
|
||||
* Set dbtype to "postgres"
|
||||
* Set dbconfig to the connection string (which you can copy from dbconfig_postgres)
|
||||
@ -25,21 +22,26 @@ The server defaults to using sqlite as the store, but can be configured to use P
|
||||
|
||||
## Running and testing the server
|
||||
|
||||
To start the server:
|
||||
```
|
||||
./bin/octoserver
|
||||
```
|
||||
To start the server, run `./bin/octoserver`
|
||||
|
||||
Server settings are in config.json.
|
||||
|
||||
Open a browser to [http://localhost:8000](http://localhost:8000) to start.
|
||||
|
||||
## Building and running the macOS app
|
||||
You can build the Mac app on a Mac running macOS Catalina (10.15.6+) and with Xcode 12.0+. A valid development signing certificate must be available.
|
||||
## Building and running standalone desktop apps
|
||||
|
||||
First build the server using the steps above, then run:
|
||||
```
|
||||
make mac
|
||||
```
|
||||
You can build standalone apps that package the server to run locally against SQLite:
|
||||
|
||||
To run, launch mac/dist/Tasks.app
|
||||
* Mac:
|
||||
* `make mac-app`
|
||||
* run `mac/dist/Tasks.app`
|
||||
* *Requires: macOS Catalina (10.15), Xcode 12 and a development signing certificate.*
|
||||
* Linux:
|
||||
* `make linux-app`
|
||||
* run `linux/dist/tasks-app`
|
||||
* Windows
|
||||
* `make win-app`
|
||||
* run `win/dist/tasks-win.exe`
|
||||
* *Requires: Windows 10*
|
||||
|
||||
Cross-compilation currently isn't fully supported, so please build on the appropriate platform.
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@ -155,7 +156,21 @@ func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
blockID := vars["blockID"]
|
||||
|
||||
blocks, err := a.app().GetSubTree(blockID)
|
||||
query := r.URL.Query()
|
||||
levels, err := strconv.ParseInt(query.Get("l"), 10, 32)
|
||||
if err != nil {
|
||||
levels = 2
|
||||
}
|
||||
|
||||
if levels != 2 && levels != 3 {
|
||||
log.Printf(`ERROR Invalid levels: %d`, levels)
|
||||
errorData := map[string]string{"description": "invalid levels"}
|
||||
errorResponse(w, http.StatusInternalServerError, errorData)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
blocks, err := a.app().GetSubTree(blockID, int(levels))
|
||||
if err != nil {
|
||||
log.Printf(`ERROR: %v`, r)
|
||||
errorResponse(w, http.StatusInternalServerError, nil)
|
||||
@ -163,7 +178,7 @@ func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("GetSubTree blockID: %s, %d result(s)", blockID, len(blocks))
|
||||
log.Printf("GetSubTree (%v) blockID: %s, %d result(s)", levels, blockID, len(blocks))
|
||||
json, err := json.Marshal(blocks)
|
||||
if err != nil {
|
||||
log.Printf(`ERROR json.Marshal: %v`, r)
|
||||
@ -302,7 +317,7 @@ func errorResponse(w http.ResponseWriter, code int, message map[string]string) {
|
||||
data = []byte("{}")
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprint(w, data)
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func addUserID(rw http.ResponseWriter, req *http.Request, next http.Handler) {
|
||||
|
@ -44,16 +44,19 @@ func (a *App) InsertBlocks(blocks []model.Block) error {
|
||||
return err
|
||||
}
|
||||
|
||||
a.wsServer.BroadcastBlockChange(block)
|
||||
go a.webhook.NotifyUpdate(block)
|
||||
}
|
||||
|
||||
a.wsServer.BroadcastBlockChangeToWebsocketClients(blockIDsToNotify)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) GetSubTree(blockID string) ([]model.Block, error) {
|
||||
return a.store.GetSubTree(blockID)
|
||||
func (a *App) GetSubTree(blockID string, levels int) ([]model.Block, error) {
|
||||
// Only 2 or 3 levels are supported for now
|
||||
if levels >= 3 {
|
||||
return a.store.GetSubTree3(blockID)
|
||||
}
|
||||
return a.store.GetSubTree2(blockID)
|
||||
}
|
||||
|
||||
func (a *App) GetAllBlocks() ([]model.Block, error) {
|
||||
@ -76,7 +79,7 @@ func (a *App) DeleteBlock(blockID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
a.wsServer.BroadcastBlockChangeToWebsocketClients(blockIDsToNotify)
|
||||
a.wsServer.BroadcastBlockDelete(blockID, parentID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/utils"
|
||||
)
|
||||
|
||||
func (a *App) SaveFile(reader io.Reader, filename string) (string, error) {
|
||||
@ -17,7 +17,7 @@ func (a *App) SaveFile(reader io.Reader, filename string) (string, error) {
|
||||
fileExtension = ".jpg"
|
||||
}
|
||||
|
||||
createdFilename := fmt.Sprintf(`%s%s`, createGUID(), fileExtension)
|
||||
createdFilename := fmt.Sprintf(`%s%s`, utils.CreateGUID(), fileExtension)
|
||||
|
||||
_, appErr := a.filesBackend.WriteFile(reader, createdFilename)
|
||||
if appErr != nil {
|
||||
@ -32,15 +32,3 @@ func (a *App) GetFilePath(filename string) string {
|
||||
|
||||
return filepath.Join(folderPath, filename)
|
||||
}
|
||||
|
||||
// CreateGUID returns a random GUID.
|
||||
func createGUID() string {
|
||||
b := make([]byte, 16)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
uuid := fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
|
||||
return uuid
|
||||
}
|
||||
|
186
server/client/client.go
Normal file
186
server/client/client.go
Normal 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)
|
||||
}
|
218
server/integrationtests/blocks_test.go
Normal file
218
server/integrationtests/blocks_test.go
Normal 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)
|
||||
})
|
||||
}
|
54
server/integrationtests/clienttestlib.go
Normal file
54
server/integrationtests/clienttestlib.go
Normal 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)
|
||||
}
|
||||
}
|
@ -1,5 +1,10 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Block is the basic data unit.
|
||||
type Block struct {
|
||||
ID string `json:"id"`
|
||||
@ -12,3 +17,9 @@ type Block struct {
|
||||
UpdateAt int64 `json:"updateAt"`
|
||||
DeleteAt int64 `json:"deleteAt"`
|
||||
}
|
||||
|
||||
func BlocksFromJSON(data io.Reader) []Block {
|
||||
var blocks []Block
|
||||
json.NewDecoder(data).Decode(&blocks)
|
||||
return blocks
|
||||
}
|
||||
|
@ -135,5 +135,13 @@ func (s *Server) Start() error {
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown() error {
|
||||
if err := s.webServer.Shutdown(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.store.Shutdown()
|
||||
}
|
||||
|
||||
func (s *Server) Config() *config.Configuration {
|
||||
return s.config
|
||||
}
|
||||
|
@ -136,19 +136,34 @@ func (mr *MockStoreMockRecorder) GetParentID(arg0 interface{}) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetParentID", reflect.TypeOf((*MockStore)(nil).GetParentID), arg0)
|
||||
}
|
||||
|
||||
// GetSubTree mocks base method
|
||||
func (m *MockStore) GetSubTree(arg0 string) ([]model.Block, error) {
|
||||
// GetSubTree2 mocks base method
|
||||
func (m *MockStore) GetSubTree2(arg0 string) ([]model.Block, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetSubTree", arg0)
|
||||
ret := m.ctrl.Call(m, "GetSubTree2", arg0)
|
||||
ret0, _ := ret[0].([]model.Block)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetSubTree indicates an expected call of GetSubTree
|
||||
func (mr *MockStoreMockRecorder) GetSubTree(arg0 interface{}) *gomock.Call {
|
||||
// GetSubTree2 indicates an expected call of GetSubTree2
|
||||
func (mr *MockStoreMockRecorder) GetSubTree2(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree", reflect.TypeOf((*MockStore)(nil).GetSubTree), arg0)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree2", reflect.TypeOf((*MockStore)(nil).GetSubTree2), arg0)
|
||||
}
|
||||
|
||||
// GetSubTree3 mocks base method
|
||||
func (m *MockStore) GetSubTree3(arg0 string) ([]model.Block, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetSubTree3", arg0)
|
||||
ret0, _ := ret[0].([]model.Block)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetSubTree3 indicates an expected call of GetSubTree3
|
||||
func (mr *MockStoreMockRecorder) GetSubTree3(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree3", reflect.TypeOf((*MockStore)(nil).GetSubTree3), arg0)
|
||||
}
|
||||
|
||||
// GetSystemSettings mocks base method
|
||||
|
@ -15,7 +15,10 @@ import (
|
||||
func (s *SQLStore) latestsBlocksSubquery() sq.SelectBuilder {
|
||||
internalQuery := sq.Select("*", "ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn").From("blocks")
|
||||
|
||||
return sq.Select("*").FromSelect(internalQuery, "a").Where(sq.Eq{"rn": 1})
|
||||
return sq.Select("*").
|
||||
FromSelect(internalQuery, "a").
|
||||
Where(sq.Eq{"rn": 1}).
|
||||
Where(sq.Eq{"delete_at": 0})
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetBlocksWithParentAndType(parentID string, blockType string) ([]model.Block, error) {
|
||||
@ -24,7 +27,6 @@ func (s *SQLStore) GetBlocksWithParentAndType(parentID string, blockType string)
|
||||
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
|
||||
"delete_at").
|
||||
FromSelect(s.latestsBlocksSubquery(), "latest").
|
||||
Where(sq.Eq{"delete_at": 0}).
|
||||
Where(sq.Eq{"parent_id": parentID}).
|
||||
Where(sq.Eq{"type": blockType})
|
||||
|
||||
@ -44,7 +46,6 @@ func (s *SQLStore) GetBlocksWithParent(parentID string) ([]model.Block, error) {
|
||||
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
|
||||
"delete_at").
|
||||
FromSelect(s.latestsBlocksSubquery(), "latest").
|
||||
Where(sq.Eq{"delete_at": 0}).
|
||||
Where(sq.Eq{"parent_id": parentID})
|
||||
|
||||
rows, err := query.Query()
|
||||
@ -63,7 +64,6 @@ func (s *SQLStore) GetBlocksWithType(blockType string) ([]model.Block, error) {
|
||||
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
|
||||
"delete_at").
|
||||
FromSelect(s.latestsBlocksSubquery(), "latest").
|
||||
Where(sq.Eq{"delete_at": 0}).
|
||||
Where(sq.Eq{"type": blockType})
|
||||
|
||||
rows, err := query.Query()
|
||||
@ -76,13 +76,13 @@ func (s *SQLStore) GetBlocksWithType(blockType string) ([]model.Block, error) {
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetSubTree(blockID string) ([]model.Block, error) {
|
||||
// GetSubTree2 returns blocks within 2 levels of the given blockID
|
||||
func (s *SQLStore) GetSubTree2(blockID string) ([]model.Block, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Select("id", "parent_id", "schema", "type", "title",
|
||||
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
|
||||
"delete_at").
|
||||
FromSelect(s.latestsBlocksSubquery(), "latest").
|
||||
Where(sq.Eq{"delete_at": 0}).
|
||||
Where(sq.Or{sq.Eq{"id": blockID}, sq.Eq{"parent_id": blockID}})
|
||||
|
||||
rows, err := query.Query()
|
||||
@ -95,13 +95,44 @@ func (s *SQLStore) GetSubTree(blockID string) ([]model.Block, error) {
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
// GetSubTree3 returns blocks within 3 levels of the given blockID
|
||||
func (s *SQLStore) GetSubTree3(blockID string) ([]model.Block, error) {
|
||||
// This first subquery returns repeated blocks
|
||||
subquery1 := sq.Select("l3.id", "l3.parent_id", "l3.schema", "l3.type", "l3.title",
|
||||
"l3.fields", "l3.create_at", "l3.update_at",
|
||||
"l3.delete_at").
|
||||
FromSelect(s.latestsBlocksSubquery(), "l1").
|
||||
JoinClause(s.latestsBlocksSubquery().Prefix("JOIN (").Suffix(") l2 on l2.parent_id = l1.id or l2.id = l1.id")).
|
||||
JoinClause(s.latestsBlocksSubquery().Prefix("JOIN (").Suffix(") l3 on l3.parent_id = l2.id or l3.id = l2.id")).
|
||||
Where(sq.Eq{"l1.id": blockID})
|
||||
|
||||
// This second subquery is used to return distinct blocks
|
||||
// We can't use DISTINCT because JSON columns in Postgres don't support it, and SQLite doesn't support DISTINCT ON
|
||||
subquery2 := sq.Select("*", "ROW_NUMBER() OVER (PARTITION BY id) AS rn").
|
||||
FromSelect(subquery1, "sub1")
|
||||
|
||||
query := s.getQueryBuilder().Select("id", "parent_id", "schema", "type", "title",
|
||||
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
|
||||
"delete_at").
|
||||
FromSelect(subquery2, "sub2").
|
||||
Where(sq.Eq{"rn": 1})
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
log.Printf(`getSubTree3 ERROR: %v`, err)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetAllBlocks() ([]model.Block, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Select("id", "parent_id", "schema", "type", "title",
|
||||
"COALESCE(\"fields\", '{}')", "create_at", "update_at",
|
||||
"delete_at").
|
||||
FromSelect(s.latestsBlocksSubquery(), "latest").
|
||||
Where(sq.Eq{"delete_at": 0})
|
||||
FromSelect(s.latestsBlocksSubquery(), "latest")
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
@ -156,7 +187,6 @@ func blocksFromRows(rows *sql.Rows) ([]model.Block, error) {
|
||||
func (s *SQLStore) GetParentID(blockID string) (string, error) {
|
||||
query := s.getQueryBuilder().Select("parent_id").
|
||||
FromSelect(s.latestsBlocksSubquery(), "latest").
|
||||
Where(sq.Eq{"delete_at": 0}).
|
||||
Where(sq.Eq{"id": blockID})
|
||||
|
||||
row := query.QueryRow()
|
||||
|
@ -36,3 +36,109 @@ func TestInsertBlock(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, blocks)
|
||||
}
|
||||
|
||||
func TestGetSubTree2(t *testing.T) {
|
||||
store, tearDown := SetupTests(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks, err := store.GetAllBlocks()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, blocks)
|
||||
|
||||
blocksToInsert := []model.Block{
|
||||
model.Block{
|
||||
ID: "parent",
|
||||
},
|
||||
model.Block{
|
||||
ID: "child1",
|
||||
ParentID: "parent",
|
||||
},
|
||||
model.Block{
|
||||
ID: "child2",
|
||||
ParentID: "parent",
|
||||
},
|
||||
model.Block{
|
||||
ID: "grandchild1",
|
||||
ParentID: "child1",
|
||||
},
|
||||
model.Block{
|
||||
ID: "grandchild2",
|
||||
ParentID: "child2",
|
||||
},
|
||||
model.Block{
|
||||
ID: "greatgrandchild1",
|
||||
ParentID: "grandchild1",
|
||||
},
|
||||
}
|
||||
|
||||
InsertBlocks(t, store, blocksToInsert)
|
||||
|
||||
blocks, err = store.GetSubTree2("parent")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, blocks, 3)
|
||||
require.True(t, ContainsBlockWithID(blocks, "parent"))
|
||||
require.True(t, ContainsBlockWithID(blocks, "child1"))
|
||||
require.True(t, ContainsBlockWithID(blocks, "child2"))
|
||||
|
||||
// Wait for not colliding the ID+insert_at key
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
DeleteBlocks(t, store, blocksToInsert)
|
||||
|
||||
blocks, err = store.GetAllBlocks()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, blocks)
|
||||
}
|
||||
|
||||
func TestGetSubTree3(t *testing.T) {
|
||||
store, tearDown := SetupTests(t)
|
||||
defer tearDown()
|
||||
|
||||
blocks, err := store.GetAllBlocks()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, blocks)
|
||||
|
||||
blocksToInsert := []model.Block{
|
||||
model.Block{
|
||||
ID: "parent",
|
||||
},
|
||||
model.Block{
|
||||
ID: "child1",
|
||||
ParentID: "parent",
|
||||
},
|
||||
model.Block{
|
||||
ID: "child2",
|
||||
ParentID: "parent",
|
||||
},
|
||||
model.Block{
|
||||
ID: "grandchild1",
|
||||
ParentID: "child1",
|
||||
},
|
||||
model.Block{
|
||||
ID: "grandchild2",
|
||||
ParentID: "child2",
|
||||
},
|
||||
model.Block{
|
||||
ID: "greatgrandchild1",
|
||||
ParentID: "grandchild1",
|
||||
},
|
||||
}
|
||||
|
||||
InsertBlocks(t, store, blocksToInsert)
|
||||
|
||||
blocks, err = store.GetSubTree3("parent")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, blocks, 5)
|
||||
require.True(t, ContainsBlockWithID(blocks, "parent"))
|
||||
require.True(t, ContainsBlockWithID(blocks, "child1"))
|
||||
require.True(t, ContainsBlockWithID(blocks, "child2"))
|
||||
require.True(t, ContainsBlockWithID(blocks, "grandchild1"))
|
||||
require.True(t, ContainsBlockWithID(blocks, "grandchild2"))
|
||||
|
||||
// Wait for not colliding the ID+insert_at key
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
DeleteBlocks(t, store, blocksToInsert)
|
||||
|
||||
blocks, err = store.GetAllBlocks()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, blocks)
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@ -28,3 +29,27 @@ func SetupTests(t *testing.T) (*SQLStore, func()) {
|
||||
|
||||
return store, tearDown
|
||||
}
|
||||
|
||||
func InsertBlocks(t *testing.T, s *SQLStore, blocks []model.Block) {
|
||||
for _, block := range blocks {
|
||||
err := s.InsertBlock(block)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteBlocks(t *testing.T, s *SQLStore, blocks []model.Block) {
|
||||
for _, block := range blocks {
|
||||
err := s.DeleteBlock(block.ID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func ContainsBlockWithID(blocks []model.Block, blockID string) bool {
|
||||
for _, block := range blocks {
|
||||
if block.ID == blockID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
@ -8,7 +8,8 @@ type Store interface {
|
||||
GetBlocksWithParentAndType(parentID string, blockType string) ([]model.Block, error)
|
||||
GetBlocksWithParent(parentID string) ([]model.Block, error)
|
||||
GetBlocksWithType(blockType string) ([]model.Block, error)
|
||||
GetSubTree(blockID string) ([]model.Block, error)
|
||||
GetSubTree2(blockID string) ([]model.Block, error)
|
||||
GetSubTree3(blockID string) ([]model.Block, error)
|
||||
GetAllBlocks() ([]model.Block, error)
|
||||
GetParentID(blockID string) (string, error)
|
||||
InsertBlock(block model.Block) error
|
||||
|
19
server/utils/utils.go
Normal file
19
server/utils/utils.go
Normal 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
|
||||
}
|
@ -20,7 +20,8 @@ type RoutedService interface {
|
||||
|
||||
// Server is the structure responsible for managing our http web server.
|
||||
type Server struct {
|
||||
router *mux.Router
|
||||
http.Server
|
||||
|
||||
rootPath string
|
||||
port int
|
||||
ssl bool
|
||||
@ -31,7 +32,10 @@ func NewServer(rootPath string, port int, ssl bool) *Server {
|
||||
r := mux.NewRouter()
|
||||
|
||||
ws := &Server{
|
||||
router: r,
|
||||
Server: http.Server{
|
||||
Addr: fmt.Sprintf(`:%d`, port),
|
||||
Handler: r,
|
||||
},
|
||||
rootPath: rootPath,
|
||||
port: port,
|
||||
ssl: ssl,
|
||||
@ -40,14 +44,18 @@ func NewServer(rootPath string, port int, ssl bool) *Server {
|
||||
return ws
|
||||
}
|
||||
|
||||
func (ws *Server) Router() *mux.Router {
|
||||
return ws.Server.Handler.(*mux.Router)
|
||||
}
|
||||
|
||||
// AddRoutes allows services to register themself in the webserver router and provide new endpoints.
|
||||
func (ws *Server) AddRoutes(rs RoutedService) {
|
||||
rs.RegisterRoutes(ws.router)
|
||||
rs.RegisterRoutes(ws.Router())
|
||||
}
|
||||
|
||||
func (ws *Server) registerRoutes() {
|
||||
ws.router.PathPrefix("/static").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(ws.rootPath, "static")))))
|
||||
ws.router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ws.Router().PathPrefix("/static").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(ws.rootPath, "static")))))
|
||||
ws.Router().PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
http.ServeFile(w, r, path.Join(ws.rootPath, "index.html"))
|
||||
})
|
||||
@ -56,14 +64,11 @@ func (ws *Server) registerRoutes() {
|
||||
// Start runs the web server and start listening for charsetnnections.
|
||||
func (ws *Server) Start() error {
|
||||
ws.registerRoutes()
|
||||
http.Handle("/", ws.router)
|
||||
|
||||
urlPort := fmt.Sprintf(`:%d`, ws.port)
|
||||
isSSL := ws.ssl && fileExists("./cert/cert.pem") && fileExists("./cert/key.pem")
|
||||
|
||||
if isSSL {
|
||||
log.Println("https server started on ", urlPort)
|
||||
err := http.ListenAndServeTLS(urlPort, "./cert/cert.pem", "./cert/key.pem", nil)
|
||||
log.Printf("https server started on :%d\n", ws.port)
|
||||
err := ws.ListenAndServeTLS("./cert/cert.pem", "./cert/key.pem")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -71,8 +76,8 @@ func (ws *Server) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("http server started on ", urlPort)
|
||||
err := http.ListenAndServe(urlPort, nil)
|
||||
log.Printf("http server started on :%d\n", ws.port)
|
||||
err := ws.ListenAndServe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -80,6 +85,10 @@ func (ws *Server) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ws *Server) Shutdown() error {
|
||||
return ws.Close()
|
||||
}
|
||||
|
||||
// fileExists returns true if a file exists at the path.
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
|
@ -5,9 +5,11 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/mattermost/mattermost-octo-tasks/server/model"
|
||||
)
|
||||
|
||||
// RegisterRoutes registers routes.
|
||||
@ -98,10 +100,10 @@ func NewServer() *Server {
|
||||
}
|
||||
}
|
||||
|
||||
// WebsocketMsg is sent on block changes.
|
||||
type WebsocketMsg struct {
|
||||
Action string `json:"action"`
|
||||
BlockID string `json:"blockId"`
|
||||
// UpdateMsg is sent on block updates
|
||||
type UpdateMsg struct {
|
||||
Action string `json:"action"`
|
||||
Block model.Block `json:"block"`
|
||||
}
|
||||
|
||||
// WebsocketCommand is an incoming command from the client.
|
||||
@ -166,16 +168,30 @@ func (ws *Server) handleWebSocketOnChange(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastBlockChangeToWebsocketClients broadcasts change to clients.
|
||||
func (ws *Server) BroadcastBlockChangeToWebsocketClients(blockIDs []string) {
|
||||
for _, blockID := range blockIDs {
|
||||
// BroadcastBlockDelete broadcasts delete messages to clients
|
||||
func (ws *Server) BroadcastBlockDelete(blockID string, parentID string) {
|
||||
now := time.Now().Unix()
|
||||
block := model.Block{}
|
||||
block.ID = blockID
|
||||
block.ParentID = parentID
|
||||
block.UpdateAt = now
|
||||
block.DeleteAt = now
|
||||
|
||||
ws.BroadcastBlockChange(block)
|
||||
}
|
||||
|
||||
// BroadcastBlockChange broadcasts update messages to clients
|
||||
func (ws *Server) BroadcastBlockChange(block model.Block) {
|
||||
blockIDsToNotify := []string{block.ID, block.ParentID}
|
||||
|
||||
for _, blockID := range blockIDsToNotify {
|
||||
listeners := ws.GetListeners(blockID)
|
||||
log.Printf("%d listener(s) for blockID: %s", len(listeners), blockID)
|
||||
|
||||
if listeners != nil {
|
||||
message := WebsocketMsg{
|
||||
Action: "UPDATE_BLOCK",
|
||||
BlockID: blockID,
|
||||
message := UpdateMsg{
|
||||
Action: "UPDATE_BLOCK",
|
||||
Block: block,
|
||||
}
|
||||
|
||||
for _, listener := range listeners {
|
||||
|
@ -26,7 +26,7 @@
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-expressions": 0,
|
||||
"babel/no-unused-expressions": 2,
|
||||
"babel/no-unused-expressions": [2, {"allowShortCircuit": true}],
|
||||
"eol-last": ["error", "always"],
|
||||
"import/no-unresolved": 2,
|
||||
"import/order": [
|
||||
@ -110,6 +110,7 @@
|
||||
"SwitchCase": 0
|
||||
}
|
||||
],
|
||||
"no-use-before-define": "off",
|
||||
"@typescript-eslint/no-use-before-define": [
|
||||
2,
|
||||
{
|
||||
@ -118,6 +119,8 @@
|
||||
"variables": false
|
||||
}
|
||||
],
|
||||
"no-useless-constructor": 0,
|
||||
"@typescript-eslint/no-useless-constructor": 2,
|
||||
"react/jsx-filename-extension": 0
|
||||
}
|
||||
},
|
||||
|
@ -6,7 +6,6 @@
|
||||
"BoardComponent.delete": "Delete",
|
||||
"BoardComponent.hidden-columns": "Hidden Columns",
|
||||
"BoardComponent.hide": "Hide",
|
||||
"BoardComponent.loading": "Loading...",
|
||||
"BoardComponent.neww": "+ New",
|
||||
"BoardComponent.no-property": "No {property}",
|
||||
"BoardComponent.no-property-title": "Items with an empty {property} property will go here. This column cannot be removed.",
|
||||
@ -16,10 +15,8 @@
|
||||
"CardDetail.add-property": "+ Add a property",
|
||||
"CardDetail.image": "Image",
|
||||
"CardDetail.new-comment-placeholder": "Add a comment...",
|
||||
"CardDetail.pick-icon": "Pick Icon",
|
||||
"CardDetail.random-icon": "Random",
|
||||
"CardDetail.remove-icon": "Remove Icon",
|
||||
"CardDetail.text": "Text",
|
||||
"CardDialog.editing-template": "You're editing a template",
|
||||
"Comment.delete": "Delete",
|
||||
"CommentsList.send": "Send",
|
||||
"Filter.includes": "includes",
|
||||
@ -31,6 +28,7 @@
|
||||
"Sidebar.add-board": "+ Add Board",
|
||||
"Sidebar.dark-theme": "Dark Theme",
|
||||
"Sidebar.delete-board": "Delete Board",
|
||||
"Sidebar.duplicate-board": "Duplicate Board",
|
||||
"Sidebar.english": "English",
|
||||
"Sidebar.export-archive": "Export Archive",
|
||||
"Sidebar.import-archive": "Import Archive",
|
||||
@ -44,7 +42,6 @@
|
||||
"Sidebar.untitled-board": "(Untitled Board)",
|
||||
"Sidebar.untitled-view": "(Untitled View)",
|
||||
"TableComponent.add-icon": "Add Icon",
|
||||
"TableComponent.loading": "Loading...",
|
||||
"TableComponent.name": "Name",
|
||||
"TableComponent.plus-new": "+ New",
|
||||
"TableHeaderMenu.delete": "Delete",
|
||||
@ -55,6 +52,12 @@
|
||||
"TableHeaderMenu.sort-ascending": "Sort ascending",
|
||||
"TableHeaderMenu.sort-descending": "Sort descending",
|
||||
"TableRow.open": "Open",
|
||||
"View.NewBoardTitle": "Board View",
|
||||
"View.NewTableTitle": "Table View",
|
||||
"ViewHeader.add-template": "+ New template",
|
||||
"ViewHeader.delete-template": "Delete",
|
||||
"ViewHeader.edit-template": "Edit",
|
||||
"ViewHeader.empty-card": "Empty card",
|
||||
"ViewHeader.export-board-archive": "Export Board Archive",
|
||||
"ViewHeader.export-csv": "Export to CSV",
|
||||
"ViewHeader.filter": "Filter",
|
||||
@ -63,10 +66,13 @@
|
||||
"ViewHeader.properties": "Properties",
|
||||
"ViewHeader.search": "Search",
|
||||
"ViewHeader.search-text": "Search text",
|
||||
"ViewHeader.select-a-template": "Select a template",
|
||||
"ViewHeader.sort": "Sort",
|
||||
"ViewHeader.test-add-100-cards": "TEST: Add 100 cards",
|
||||
"ViewHeader.test-add-1000-cards": "TEST: Add 1,000 cards",
|
||||
"ViewHeader.test-distribute-cards": "TEST: Distribute cards",
|
||||
"ViewHeader.test-randomize-icons": "TEST: Randomize icons",
|
||||
"ViewHeader.untitled": "Untitled",
|
||||
"ViewTitle.pick-icon": "Pick Icon",
|
||||
"ViewTitle.random-icon": "Random",
|
||||
"ViewTitle.remove-icon": "Remove Icon",
|
||||
|
@ -58,7 +58,7 @@ class Archiver {
|
||||
input.type = 'file'
|
||||
input.accept = '.octo'
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0]
|
||||
const file = input.files && input.files[0]
|
||||
const contents = await (new Response(file)).text()
|
||||
Utils.log(`Import ${contents.length} bytes.`)
|
||||
const archive: Archive = JSON.parse(contents)
|
||||
|
@ -2,13 +2,15 @@
|
||||
// See LICENSE.txt for license information.
|
||||
import {Utils} from '../utils'
|
||||
|
||||
type BlockTypes = 'board' | 'view' | 'card' | 'text' | 'image' | 'divider' | 'comment'
|
||||
|
||||
interface IBlock {
|
||||
readonly id: string
|
||||
readonly parentId: string
|
||||
|
||||
readonly schema: number
|
||||
readonly type: string
|
||||
readonly title?: string
|
||||
readonly type: BlockTypes
|
||||
readonly title: string
|
||||
readonly fields: Readonly<Record<string, any>>
|
||||
|
||||
readonly createAt: number
|
||||
@ -21,8 +23,8 @@ interface IMutableBlock extends IBlock {
|
||||
parentId: string
|
||||
|
||||
schema: number
|
||||
type: string
|
||||
title?: string
|
||||
type: BlockTypes
|
||||
title: string
|
||||
fields: Record<string, any>
|
||||
|
||||
createAt: number
|
||||
@ -34,19 +36,18 @@ class MutableBlock implements IMutableBlock {
|
||||
id: string = Utils.createGuid()
|
||||
schema: number
|
||||
parentId: string
|
||||
type: string
|
||||
type: BlockTypes
|
||||
title: string
|
||||
fields: Record<string, any> = {}
|
||||
createAt: number = Date.now()
|
||||
updateAt = 0
|
||||
deleteAt = 0
|
||||
|
||||
static duplicate(block: IBlock): IBlock {
|
||||
static duplicate(block: IBlock): IMutableBlock {
|
||||
const now = Date.now()
|
||||
|
||||
const newBlock = new MutableBlock(block)
|
||||
newBlock.id = Utils.createGuid()
|
||||
newBlock.title = `Copy of ${block.title}`
|
||||
newBlock.createAt = now
|
||||
newBlock.updateAt = now
|
||||
newBlock.deleteAt = 0
|
||||
@ -55,18 +56,17 @@ class MutableBlock implements IMutableBlock {
|
||||
}
|
||||
|
||||
constructor(block: any = {}) {
|
||||
const now = Date.now()
|
||||
|
||||
this.id = block.id || Utils.createGuid()
|
||||
this.schema = 1
|
||||
this.parentId = block.parentId
|
||||
this.type = block.type
|
||||
this.parentId = block.parentId || ''
|
||||
this.type = block.type || ''
|
||||
|
||||
// Shallow copy here. Derived classes must make deep copies of their known properties in their constructors.
|
||||
this.fields = block.fields ? {...block.fields} : {}
|
||||
|
||||
this.title = block.title
|
||||
this.title = block.title || ''
|
||||
|
||||
const now = Date.now()
|
||||
this.createAt = block.createAt || now
|
||||
this.updateAt = block.updateAt || now
|
||||
this.deleteAt = block.deleteAt || 0
|
||||
|
@ -57,6 +57,7 @@ class MutableBoard extends MutableBlock {
|
||||
super(block)
|
||||
this.type = 'board'
|
||||
|
||||
this.icon = block.fields?.icon || ''
|
||||
if (block.fields?.cardProperties) {
|
||||
// Deep clone of card properties and their options
|
||||
this.cardProperties = block.fields.cardProperties.map((o: IPropertyTemplate) => {
|
||||
|
@ -10,17 +10,17 @@ type ISortOption = { propertyId: '__title' | string, reversed: boolean }
|
||||
|
||||
interface BoardView extends IBlock {
|
||||
readonly viewType: IViewType
|
||||
readonly groupById: string
|
||||
readonly groupById?: string
|
||||
readonly sortOptions: readonly ISortOption[]
|
||||
readonly visiblePropertyIds: readonly string[]
|
||||
readonly visibleOptionIds: readonly string[]
|
||||
readonly hiddenOptionIds: readonly string[]
|
||||
readonly filter: FilterGroup | undefined
|
||||
readonly filter: FilterGroup
|
||||
readonly cardOrder: readonly string[]
|
||||
readonly columnWidths: Readonly<Record<string, number>>
|
||||
}
|
||||
|
||||
class MutableBoardView extends MutableBlock {
|
||||
class MutableBoardView extends MutableBlock implements BoardView {
|
||||
get viewType(): IViewType {
|
||||
return this.fields.viewType
|
||||
}
|
||||
@ -63,10 +63,10 @@ class MutableBoardView extends MutableBlock {
|
||||
this.fields.hiddenOptionIds = value
|
||||
}
|
||||
|
||||
get filter(): FilterGroup | undefined {
|
||||
get filter(): FilterGroup {
|
||||
return this.fields.filter
|
||||
}
|
||||
set filter(value: FilterGroup | undefined) {
|
||||
set filter(value: FilterGroup) {
|
||||
this.fields.filter = value
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,15 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {Utils} from '../utils'
|
||||
import {IBlock} from '../blocks/block'
|
||||
|
||||
import {MutableBlock} from './block'
|
||||
|
||||
interface Card extends IBlock {
|
||||
readonly icon: string
|
||||
readonly isTemplate: boolean
|
||||
readonly properties: Readonly<Record<string, string>>
|
||||
duplicate(): MutableCard
|
||||
}
|
||||
|
||||
class MutableCard extends MutableBlock {
|
||||
@ -17,6 +20,13 @@ class MutableCard extends MutableBlock {
|
||||
this.fields.icon = value
|
||||
}
|
||||
|
||||
get isTemplate(): boolean {
|
||||
return this.fields.isTemplate as boolean
|
||||
}
|
||||
set isTemplate(value: boolean) {
|
||||
this.fields.isTemplate = value
|
||||
}
|
||||
|
||||
get properties(): Record<string, string> {
|
||||
return this.fields.properties as Record<string, string>
|
||||
}
|
||||
@ -28,8 +38,15 @@ class MutableCard extends MutableBlock {
|
||||
super(block)
|
||||
this.type = 'card'
|
||||
|
||||
this.icon = block.fields?.icon || ''
|
||||
this.properties = {...(block.fields?.properties || {})}
|
||||
}
|
||||
|
||||
duplicate(): MutableCard {
|
||||
const card = new MutableCard(this)
|
||||
card.id = Utils.createGuid()
|
||||
return card
|
||||
}
|
||||
}
|
||||
|
||||
export {MutableCard, Card}
|
||||
|
@ -17,6 +17,7 @@ class MutableImageBlock extends MutableOrderedBlock implements IOrderedBlock {
|
||||
constructor(block: any = {}) {
|
||||
super(block)
|
||||
this.type = 'image'
|
||||
this.url = block.fields?.url || ''
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,7 @@ class MutableOrderedBlock extends MutableBlock implements IOrderedBlock {
|
||||
|
||||
constructor(block: any = {}) {
|
||||
super(block)
|
||||
this.order = block.fields?.order || 0
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -71,8 +71,12 @@ class CardFilter {
|
||||
return true
|
||||
}
|
||||
|
||||
static propertiesThatMeetFilterGroup(filterGroup: FilterGroup, templates: readonly IPropertyTemplate[]): Record<string, string> {
|
||||
static propertiesThatMeetFilterGroup(filterGroup: FilterGroup | undefined, templates: readonly IPropertyTemplate[]): Record<string, string> {
|
||||
// TODO: Handle filter groups
|
||||
if (!filterGroup) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const filters = filterGroup.filters.filter((o) => !FilterGroup.isAnInstanceOf(o))
|
||||
if (filters.length < 1) {
|
||||
return {}
|
||||
@ -82,19 +86,30 @@ class CardFilter {
|
||||
// Just need to meet the first clause
|
||||
const property = this.propertyThatMeetsFilterClause(filters[0] as FilterClause, templates)
|
||||
const result: Record<string, string> = {}
|
||||
result[property.id] = property.value
|
||||
if (property.value) {
|
||||
result[property.id] = property.value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// And: Need to meet all clauses
|
||||
const result: Record<string, string> = {}
|
||||
filters.forEach((filterClause) => {
|
||||
const p = this.propertyThatMeetsFilterClause(filterClause as FilterClause, templates)
|
||||
result[p.id] = p.value
|
||||
const property = this.propertyThatMeetsFilterClause(filterClause as FilterClause, templates)
|
||||
if (property.value) {
|
||||
result[property.id] = property.value
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
static propertyThatMeetsFilterClause(filterClause: FilterClause, templates: readonly IPropertyTemplate[]): { id: string, value?: string } {
|
||||
const template = templates.find((o) => o.id === filterClause.propertyId)
|
||||
if (!template) {
|
||||
Utils.assertFailure(`propertyThatMeetsFilterClause. Cannot find template: ${filterClause.propertyId}`)
|
||||
return {id: filterClause.propertyId}
|
||||
}
|
||||
|
||||
switch (filterClause.condition) {
|
||||
case 'includes': {
|
||||
if (filterClause.values.length < 1) {
|
||||
@ -108,7 +123,12 @@ class CardFilter {
|
||||
}
|
||||
if (template.type === 'select') {
|
||||
const option = template.options.find((o) => !filterClause.values.includes(o.id))
|
||||
return {id: filterClause.propertyId, value: option.id}
|
||||
if (option) {
|
||||
return {id: filterClause.propertyId, value: option.id}
|
||||
}
|
||||
|
||||
// No other options exist
|
||||
return {id: filterClause.propertyId}
|
||||
}
|
||||
|
||||
// TODO: Handle non-select types
|
||||
|
@ -7,12 +7,11 @@ import {BlockIcons} from '../blockIcons'
|
||||
import {Board} from '../blocks/board'
|
||||
import {Card} from '../blocks/card'
|
||||
import mutator from '../mutator'
|
||||
import EmojiPicker from '../widgets/emojiPicker'
|
||||
import DeleteIcon from '../widgets/icons/delete'
|
||||
import EmojiIcon from '../widgets/icons/emoji'
|
||||
import Menu from '../widgets/menu'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
import EmojiPicker from '../widgets/emojiPicker'
|
||||
import EmojiIcon from '../widgets/icons/emoji'
|
||||
import DeleteIcon from '../widgets/icons/delete'
|
||||
|
||||
import './blockIconSelector.scss'
|
||||
|
||||
type Props = {
|
||||
@ -37,7 +36,7 @@ class BlockIconSelector extends React.Component<Props> {
|
||||
document.body.click()
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
render(): JSX.Element | null {
|
||||
const {block, intl, size} = this.props
|
||||
if (!block.icon) {
|
||||
return null
|
||||
@ -64,7 +63,7 @@ class BlockIconSelector extends React.Component<Props> {
|
||||
id='remove'
|
||||
icon={<DeleteIcon/>}
|
||||
name={intl.formatMessage({id: 'ViewTitle.remove-icon', defaultMessage: 'Remove Icon'})}
|
||||
onClick={() => mutator.changeIcon(block, undefined, 'remove icon')}
|
||||
onClick={() => mutator.changeIcon(block, '', 'remove icon')}
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
|
@ -3,21 +3,18 @@
|
||||
import React from 'react'
|
||||
import {injectIntl, IntlShape} from 'react-intl'
|
||||
|
||||
import {MutableBlock} from '../blocks/block'
|
||||
|
||||
import {IPropertyTemplate} from '../blocks/board'
|
||||
import {Card} from '../blocks/card'
|
||||
import mutator from '../mutator'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
import Menu from '../widgets/menu'
|
||||
import OptionsIcon from '../widgets/icons/options'
|
||||
import IconButton from '../widgets/buttons/iconButton'
|
||||
import DeleteIcon from '../widgets/icons/delete'
|
||||
import DuplicateIcon from '../widgets/icons/duplicate'
|
||||
import IconButton from '../widgets/buttons/iconButton'
|
||||
|
||||
import PropertyValueElement from './propertyValueElement'
|
||||
import OptionsIcon from '../widgets/icons/options'
|
||||
import Menu from '../widgets/menu'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
|
||||
import './boardCard.scss'
|
||||
import PropertyValueElement from './propertyValueElement'
|
||||
|
||||
type BoardCardProps = {
|
||||
card: Card
|
||||
@ -25,9 +22,9 @@ type BoardCardProps = {
|
||||
isSelected: boolean
|
||||
isDropZone?: boolean
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
|
||||
onDragStart?: (e: React.DragEvent<HTMLDivElement>) => void
|
||||
onDragEnd?: (e: React.DragEvent<HTMLDivElement>) => void
|
||||
onDrop?: (e: React.DragEvent<HTMLDivElement>) => void
|
||||
onDragStart: (e: React.DragEvent<HTMLDivElement>) => void
|
||||
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>) => void
|
||||
intl: IntlShape
|
||||
}
|
||||
|
||||
@ -69,13 +66,17 @@ class BoardCard extends React.Component<BoardCardProps, BoardCardState> {
|
||||
this.props.onDragEnd(e)
|
||||
}}
|
||||
|
||||
onDragOver={(e) => {
|
||||
this.setState({isDragOver: true})
|
||||
onDragOver={() => {
|
||||
if (!this.state.isDragOver) {
|
||||
this.setState({isDragOver: true})
|
||||
}
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
this.setState({isDragOver: true})
|
||||
onDragEnter={() => {
|
||||
if (!this.state.isDragOver) {
|
||||
this.setState({isDragOver: true})
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
onDragLeave={() => {
|
||||
this.setState({isDragOver: false})
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
@ -101,7 +102,9 @@ class BoardCard extends React.Component<BoardCardProps, BoardCardState> {
|
||||
icon={<DuplicateIcon/>}
|
||||
id='duplicate'
|
||||
name={intl.formatMessage({id: 'BoardCard.duplicate', defaultMessage: 'Duplicate'})}
|
||||
onClick={() => mutator.insertBlock(MutableBlock.duplicate(card), 'duplicate card')}
|
||||
onClick={() => {
|
||||
mutator.duplicateCard(card.id)
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
|
@ -3,44 +3,45 @@
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
onDrop?: (e: React.DragEvent<HTMLDivElement>) => void
|
||||
isDropZone?: boolean
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>) => void
|
||||
isDropZone: boolean
|
||||
}
|
||||
|
||||
type State = {
|
||||
isDragOver?: boolean
|
||||
dragPageX?: number
|
||||
dragPageY?: number
|
||||
isDragOver: boolean
|
||||
}
|
||||
|
||||
class BoardColumn extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
class BoardColumn extends React.PureComponent<Props, State> {
|
||||
state = {
|
||||
isDragOver: false,
|
||||
}
|
||||
|
||||
render() {
|
||||
render(): JSX.Element {
|
||||
let className = 'octo-board-column'
|
||||
if (this.props.isDropZone && this.state.isDragOver) {
|
||||
className += ' dragover'
|
||||
}
|
||||
const element =
|
||||
(<div
|
||||
const element = (
|
||||
<div
|
||||
className={className}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
this.setState({isDragOver: true, dragPageX: e.pageX, dragPageY: e.pageY})
|
||||
if (!this.state.isDragOver) {
|
||||
this.setState({isDragOver: true})
|
||||
}
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault()
|
||||
this.setState({isDragOver: true, dragPageX: e.pageX, dragPageY: e.pageY})
|
||||
if (!this.state.isDragOver) {
|
||||
this.setState({isDragOver: true})
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault()
|
||||
this.setState({isDragOver: false, dragPageX: undefined, dragPageY: undefined})
|
||||
this.setState({isDragOver: false})
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
this.setState({isDragOver: false, dragPageX: undefined, dragPageY: undefined})
|
||||
this.setState({isDragOver: false})
|
||||
if (this.props.isDropZone) {
|
||||
this.props.onDrop(e)
|
||||
}
|
||||
|
@ -2,46 +2,47 @@
|
||||
// See LICENSE.txt for license information.
|
||||
/* eslint-disable max-lines */
|
||||
import React from 'react'
|
||||
import {injectIntl, IntlShape, FormattedMessage} from 'react-intl'
|
||||
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
|
||||
|
||||
import {BlockIcons} from '../blockIcons'
|
||||
import {IBlock} from '../blocks/block'
|
||||
import {IPropertyOption, IPropertyTemplate} from '../blocks/board'
|
||||
import {Card, MutableCard} from '../blocks/card'
|
||||
import {BoardTree, BoardTreeGroup} from '../viewModel/boardTree'
|
||||
import {CardFilter} from '../cardFilter'
|
||||
import {Constants} from '../constants'
|
||||
import mutator from '../mutator'
|
||||
import {Utils} from '../utils'
|
||||
import Menu from '../widgets/menu'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
import OptionsIcon from '../widgets/icons/options'
|
||||
import AddIcon from '../widgets/icons/add'
|
||||
import HideIcon from '../widgets/icons/hide'
|
||||
import ShowIcon from '../widgets/icons/show'
|
||||
import DeleteIcon from '../widgets/icons/delete'
|
||||
import {BoardTree, BoardTreeGroup} from '../viewModel/boardTree'
|
||||
import {MutableCardTree} from '../viewModel/cardTree'
|
||||
import Button from '../widgets/buttons/button'
|
||||
import IconButton from '../widgets/buttons/iconButton'
|
||||
import AddIcon from '../widgets/icons/add'
|
||||
import DeleteIcon from '../widgets/icons/delete'
|
||||
import HideIcon from '../widgets/icons/hide'
|
||||
import OptionsIcon from '../widgets/icons/options'
|
||||
import ShowIcon from '../widgets/icons/show'
|
||||
import Menu from '../widgets/menu'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
|
||||
import BoardCard from './boardCard'
|
||||
import {BoardColumn} from './boardColumn'
|
||||
import './boardComponent.scss'
|
||||
import {CardDialog} from './cardDialog'
|
||||
import {Editable} from './editable'
|
||||
import RootPortal from './rootPortal'
|
||||
import ViewHeader from './viewHeader'
|
||||
import ViewTitle from './viewTitle'
|
||||
|
||||
import './boardComponent.scss'
|
||||
|
||||
type Props = {
|
||||
boardTree?: BoardTree
|
||||
boardTree: BoardTree
|
||||
showView: (id: string) => void
|
||||
setSearchText: (text: string) => void
|
||||
setSearchText: (text?: string) => void
|
||||
intl: IntlShape
|
||||
}
|
||||
|
||||
type State = {
|
||||
isSearching: boolean
|
||||
shownCard?: Card
|
||||
shownCardId?: string
|
||||
viewMenu: boolean
|
||||
selectedCardIds: string[]
|
||||
showFilter: boolean
|
||||
@ -49,7 +50,7 @@ type State = {
|
||||
|
||||
class BoardComponent extends React.Component<Props, State> {
|
||||
private draggedCards: Card[] = []
|
||||
private draggedHeaderOption: IPropertyOption
|
||||
private draggedHeaderOption?: IPropertyOption
|
||||
private backgroundRef = React.createRef<HTMLDivElement>()
|
||||
private searchFieldRef = React.createRef<Editable>()
|
||||
|
||||
@ -83,7 +84,7 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
isSearching: Boolean(this.props.boardTree?.getSearchText()),
|
||||
isSearching: Boolean(this.props.boardTree.getSearchText()),
|
||||
viewMenu: false,
|
||||
selectedCardIds: [],
|
||||
showFilter: false,
|
||||
@ -96,25 +97,20 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
|
||||
componentDidUpdate(prevPros: Props, prevState: State): void {
|
||||
if (this.state.isSearching && !prevState.isSearching) {
|
||||
this.searchFieldRef.current.focus()
|
||||
this.searchFieldRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
const {boardTree, showView} = this.props
|
||||
const {groupByProperty} = boardTree
|
||||
|
||||
if (!boardTree || !boardTree.board) {
|
||||
return (
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id='BoardComponent.loading'
|
||||
defaultMessage='Loading...'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
if (!groupByProperty) {
|
||||
Utils.assertFailure('Board views must have groupByProperty set')
|
||||
return <div/>
|
||||
}
|
||||
|
||||
const propertyValues = boardTree.groupByProperty?.options || []
|
||||
const propertyValues = groupByProperty.options || []
|
||||
Utils.log(`${propertyValues.length} propertyValues`)
|
||||
|
||||
const {board, activeView, visibleGroups, hiddenGroups} = boardTree
|
||||
@ -129,12 +125,14 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
this.backgroundClicked(e)
|
||||
}}
|
||||
>
|
||||
{this.state.shownCard &&
|
||||
{this.state.shownCardId &&
|
||||
<RootPortal>
|
||||
<CardDialog
|
||||
key={this.state.shownCardId}
|
||||
boardTree={boardTree}
|
||||
card={this.state.shownCard}
|
||||
onClose={() => this.setState({shownCard: undefined})}
|
||||
cardId={this.state.shownCardId}
|
||||
onClose={() => this.setState({shownCardId: undefined})}
|
||||
showCard={(cardId) => this.setState({shownCardId: cardId})}
|
||||
/>
|
||||
</RootPortal>}
|
||||
|
||||
@ -150,6 +148,9 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
showView={showView}
|
||||
setSearchText={this.props.setSearchText}
|
||||
addCard={() => this.addCard()}
|
||||
addCardFromTemplate={this.addCardFromTemplate}
|
||||
addCardTemplate={this.addCardTemplate}
|
||||
editCardTemplate={this.editCardTemplate}
|
||||
withGroupBy={true}
|
||||
/>
|
||||
<div
|
||||
@ -237,7 +238,11 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
this.cardClicked(e, card)
|
||||
}}
|
||||
onDragStart={() => {
|
||||
this.draggedCards = this.state.selectedCardIds.includes(card.id) ? this.state.selectedCardIds.map((id) => boardTree.allCards.find((o) => o.id === id)) : [card]
|
||||
if (this.state.selectedCardIds.includes(card.id)) {
|
||||
this.draggedCards = this.state.selectedCardIds.map((id) => boardTree.allCards.find((o) => o.id === id)!)
|
||||
} else {
|
||||
this.draggedCards = [card]
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
this.draggedCards = []
|
||||
@ -274,19 +279,19 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
}}
|
||||
|
||||
onDragOver={(e) => {
|
||||
ref.current.classList.add('dragover')
|
||||
ref.current!.classList.add('dragover')
|
||||
e.preventDefault()
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
ref.current.classList.add('dragover')
|
||||
ref.current!.classList.add('dragover')
|
||||
e.preventDefault()
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
ref.current.classList.remove('dragover')
|
||||
ref.current!.classList.remove('dragover')
|
||||
e.preventDefault()
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
ref.current.classList.remove('dragover')
|
||||
ref.current!.classList.remove('dragover')
|
||||
e.preventDefault()
|
||||
this.onDropToColumn(group.option)
|
||||
}}
|
||||
@ -296,13 +301,13 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
title={intl.formatMessage({
|
||||
id: 'BoardComponent.no-property-title',
|
||||
defaultMessage: 'Items with an empty {property} property will go here. This column cannot be removed.',
|
||||
}, {property: boardTree.groupByProperty?.name})}
|
||||
}, {property: boardTree.groupByProperty!.name})}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='BoardComponent.no-property'
|
||||
defaultMessage='No {property}'
|
||||
values={{
|
||||
property: boardTree.groupByProperty?.name,
|
||||
property: boardTree.groupByProperty!.name,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -343,19 +348,19 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
}}
|
||||
|
||||
onDragOver={(e) => {
|
||||
ref.current.classList.add('dragover')
|
||||
ref.current!.classList.add('dragover')
|
||||
e.preventDefault()
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
ref.current.classList.add('dragover')
|
||||
ref.current!.classList.add('dragover')
|
||||
e.preventDefault()
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
ref.current.classList.remove('dragover')
|
||||
ref.current!.classList.remove('dragover')
|
||||
e.preventDefault()
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
ref.current.classList.remove('dragover')
|
||||
ref.current!.classList.remove('dragover')
|
||||
e.preventDefault()
|
||||
this.onDropToColumn(group.option)
|
||||
}}
|
||||
@ -384,7 +389,7 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
id='delete'
|
||||
icon={<DeleteIcon/>}
|
||||
name={intl.formatMessage({id: 'BoardComponent.delete', defaultMessage: 'Delete'})}
|
||||
onClick={() => mutator.deletePropertyOption(boardTree, boardTree.groupByProperty, group.option)}
|
||||
onClick={() => mutator.deletePropertyOption(boardTree, boardTree.groupByProperty!, group.option)}
|
||||
/>
|
||||
<Menu.Separator/>
|
||||
{Constants.menuColors.map((color) => (
|
||||
@ -392,7 +397,7 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
key={color.id}
|
||||
id={color.id}
|
||||
name={color.name}
|
||||
onClick={() => mutator.changePropertyOptionColor(boardTree.board, boardTree.groupByProperty, group.option, color.id)}
|
||||
onClick={() => mutator.changePropertyOptionColor(boardTree.board, boardTree.groupByProperty!, group.option, color.id)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
@ -419,25 +424,25 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
if (this.draggedCards?.length < 1) {
|
||||
return
|
||||
}
|
||||
ref.current.classList.add('dragover')
|
||||
ref.current!.classList.add('dragover')
|
||||
e.preventDefault()
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
if (this.draggedCards?.length < 1) {
|
||||
return
|
||||
}
|
||||
ref.current.classList.add('dragover')
|
||||
ref.current!.classList.add('dragover')
|
||||
e.preventDefault()
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (this.draggedCards?.length < 1) {
|
||||
return
|
||||
}
|
||||
ref.current.classList.remove('dragover')
|
||||
ref.current!.classList.remove('dragover')
|
||||
e.preventDefault()
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
(e.target as HTMLElement).classList.remove('dragover')
|
||||
ref.current!.classList.remove('dragover')
|
||||
e.preventDefault()
|
||||
if (this.draggedCards?.length < 1) {
|
||||
return
|
||||
@ -473,32 +478,77 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
private async addCard(groupByOptionId?: string): Promise<void> {
|
||||
private addCardFromTemplate = async (cardTemplateId?: string) => {
|
||||
this.addCard(undefined, cardTemplateId)
|
||||
}
|
||||
|
||||
private async addCard(groupByOptionId?: string, cardTemplateId?: string): Promise<void> {
|
||||
const {boardTree} = this.props
|
||||
const {activeView, board} = boardTree
|
||||
|
||||
const card = new MutableCard()
|
||||
let card: MutableCard
|
||||
let blocksToInsert: IBlock[]
|
||||
if (cardTemplateId) {
|
||||
const templateCardTree = new MutableCardTree(cardTemplateId)
|
||||
await templateCardTree.sync()
|
||||
const newCardTree = templateCardTree.templateCopy()
|
||||
card = newCardTree.card
|
||||
card.isTemplate = false
|
||||
card.title = ''
|
||||
blocksToInsert = [newCardTree.card, ...newCardTree.contents]
|
||||
} else {
|
||||
card = new MutableCard()
|
||||
blocksToInsert = [card]
|
||||
}
|
||||
|
||||
card.parentId = boardTree.board.id
|
||||
card.properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
|
||||
card.icon = BlockIcons.shared.randomIcon()
|
||||
const propertiesThatMeetFilters = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
|
||||
if (boardTree.groupByProperty) {
|
||||
if (groupByOptionId) {
|
||||
card.properties[boardTree.groupByProperty.id] = groupByOptionId
|
||||
propertiesThatMeetFilters[boardTree.groupByProperty.id] = groupByOptionId
|
||||
} else {
|
||||
delete card.properties[boardTree.groupByProperty.id]
|
||||
delete propertiesThatMeetFilters[boardTree.groupByProperty.id]
|
||||
}
|
||||
}
|
||||
await mutator.insertBlock(card, 'add card', async () => {
|
||||
this.setState({shownCard: card})
|
||||
}, async () => {
|
||||
this.setState({shownCard: undefined})
|
||||
})
|
||||
card.properties = {...card.properties, ...propertiesThatMeetFilters}
|
||||
card.icon = BlockIcons.shared.randomIcon()
|
||||
await mutator.insertBlocks(
|
||||
blocksToInsert,
|
||||
'add card',
|
||||
async () => {
|
||||
this.setState({shownCardId: card.id})
|
||||
},
|
||||
async () => {
|
||||
this.setState({shownCardId: undefined})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private addCardTemplate = async () => {
|
||||
const {boardTree} = this.props
|
||||
|
||||
const cardTemplate = new MutableCard()
|
||||
cardTemplate.isTemplate = true
|
||||
cardTemplate.parentId = boardTree.board.id
|
||||
await mutator.insertBlock(
|
||||
cardTemplate,
|
||||
'add card template',
|
||||
async () => {
|
||||
this.setState({shownCardId: cardTemplate.id})
|
||||
}, async () => {
|
||||
this.setState({shownCardId: undefined})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private editCardTemplate = (cardTemplateId: string) => {
|
||||
this.setState({shownCardId: cardTemplateId})
|
||||
}
|
||||
|
||||
private async propertyNameChanged(option: IPropertyOption, text: string): Promise<void> {
|
||||
const {boardTree} = this.props
|
||||
|
||||
await mutator.changePropertyOptionValue(boardTree, boardTree.groupByProperty, option, text)
|
||||
await mutator.changePropertyOptionValue(boardTree, boardTree.groupByProperty!, option, text)
|
||||
}
|
||||
|
||||
private cardClicked(e: React.MouseEvent, card: Card): void {
|
||||
@ -528,7 +578,7 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
this.setState({selectedCardIds})
|
||||
}
|
||||
} else {
|
||||
this.setState({selectedCardIds: [], shownCard: card})
|
||||
this.setState({selectedCardIds: [], shownCardId: card.id})
|
||||
}
|
||||
|
||||
e.stopPropagation()
|
||||
@ -545,8 +595,7 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
color: 'propColorDefault',
|
||||
}
|
||||
|
||||
Utils.assert(boardTree.groupByProperty)
|
||||
await mutator.insertPropertyOption(boardTree, boardTree.groupByProperty, option, 'add group')
|
||||
await mutator.insertPropertyOption(boardTree, boardTree.groupByProperty!, option, 'add group')
|
||||
}
|
||||
|
||||
private async onDropToColumn(option: IPropertyOption) {
|
||||
@ -554,23 +603,23 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
const {draggedCards, draggedHeaderOption} = this
|
||||
const optionId = option ? option.id : undefined
|
||||
|
||||
Utils.assertValue(mutator)
|
||||
Utils.assertValue(boardTree)
|
||||
|
||||
if (draggedCards.length > 0) {
|
||||
await mutator.performAsUndoGroup(async () => {
|
||||
const description = draggedCards.length > 1 ? `drag ${draggedCards.length} cards` : 'drag card'
|
||||
const awaits = []
|
||||
for (const draggedCard of draggedCards) {
|
||||
Utils.log(`ondrop. Card: ${draggedCard.title}, column: ${optionId}`)
|
||||
const oldValue = draggedCard.properties[boardTree.groupByProperty.id]
|
||||
const oldValue = draggedCard.properties[boardTree.groupByProperty!.id]
|
||||
if (optionId !== oldValue) {
|
||||
await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty.id, optionId, description)
|
||||
awaits.push(mutator.changePropertyValue(draggedCard, boardTree.groupByProperty!.id, optionId, description))
|
||||
}
|
||||
}
|
||||
await Promise.all(awaits)
|
||||
})
|
||||
} else if (draggedHeaderOption) {
|
||||
Utils.log(`ondrop. Header option: ${draggedHeaderOption.value}, column: ${option?.value}`)
|
||||
Utils.assertValue(boardTree.groupByProperty)
|
||||
|
||||
// Move option to new index
|
||||
const visibleOptionIds = boardTree.visibleGroups.map((o) => o.option.id)
|
||||
@ -590,7 +639,7 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
const {boardTree} = this.props
|
||||
const {activeView} = boardTree
|
||||
const {draggedCards} = this
|
||||
const optionId = card.properties[activeView.groupById]
|
||||
const optionId = card.properties[activeView.groupById!]
|
||||
|
||||
if (draggedCards.length < 1 || draggedCards.includes(card)) {
|
||||
return
|
||||
@ -605,7 +654,7 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
const isDraggingDown = cardOrder.indexOf(firstDraggedCard.id) <= cardOrder.indexOf(card.id)
|
||||
cardOrder = cardOrder.filter((id) => !draggedCardIds.includes(id))
|
||||
let destIndex = cardOrder.indexOf(card.id)
|
||||
if (firstDraggedCard.properties[boardTree.groupByProperty.id] === optionId && isDraggingDown) {
|
||||
if (firstDraggedCard.properties[boardTree.groupByProperty!.id] === optionId && isDraggingDown) {
|
||||
// If the cards are in the same column and dragging down, drop after the target card
|
||||
destIndex += 1
|
||||
}
|
||||
@ -613,14 +662,15 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
|
||||
await mutator.performAsUndoGroup(async () => {
|
||||
// Update properties of dragged cards
|
||||
const awaits = []
|
||||
for (const draggedCard of draggedCards) {
|
||||
Utils.log(`draggedCard: ${draggedCard.title}, column: ${optionId}`)
|
||||
const oldOptionId = draggedCard.properties[boardTree.groupByProperty.id]
|
||||
const oldOptionId = draggedCard.properties[boardTree.groupByProperty!.id]
|
||||
if (optionId !== oldOptionId) {
|
||||
await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty.id, optionId, description)
|
||||
awaits.push(mutator.changePropertyValue(draggedCard, boardTree.groupByProperty!.id, optionId, description))
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(awaits)
|
||||
await mutator.changeViewCardOrder(activeView, cardOrder, description)
|
||||
})
|
||||
}
|
||||
@ -634,7 +684,11 @@ class BoardComponent extends React.Component<Props, State> {
|
||||
mutator.performAsUndoGroup(async () => {
|
||||
for (const cardId of selectedCardIds) {
|
||||
const card = this.props.boardTree.allCards.find((o) => o.id === cardId)
|
||||
mutator.deleteBlock(card, selectedCardIds.length > 1 ? `delete ${selectedCardIds.length} cards` : 'delete card')
|
||||
if (card) {
|
||||
mutator.deleteBlock(card, selectedCardIds.length > 1 ? `delete ${selectedCardIds.length} cards` : 'delete card')
|
||||
} else {
|
||||
Utils.assertFailure(`Selected card not found: ${cardId}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -24,7 +24,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
.MenuWrapper: {
|
||||
.MenuWrapper {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
@ -7,9 +7,8 @@ import {BlockIcons} from '../blockIcons'
|
||||
import {MutableTextBlock} from '../blocks/textBlock'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import {PropertyType} from '../blocks/board'
|
||||
import {CardTree, MutableCardTree} from '../viewModel/cardTree'
|
||||
import {CardTree} from '../viewModel/cardTree'
|
||||
import mutator from '../mutator'
|
||||
import {OctoListener} from '../octoListener'
|
||||
import {Utils} from '../utils'
|
||||
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
@ -29,56 +28,34 @@ import './cardDetail.scss'
|
||||
|
||||
type Props = {
|
||||
boardTree: BoardTree
|
||||
cardId: string
|
||||
cardTree: CardTree
|
||||
intl: IntlShape
|
||||
}
|
||||
|
||||
type State = {
|
||||
cardTree?: CardTree
|
||||
title: string
|
||||
}
|
||||
|
||||
class CardDetail extends React.Component<Props, State> {
|
||||
private titleRef = React.createRef<Editable>()
|
||||
private cardListener?: OctoListener
|
||||
|
||||
shouldComponentUpdate() {
|
||||
shouldComponentUpdate(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.titleRef.current?.focus()
|
||||
}
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
title: '',
|
||||
title: props.cardTree.card.title,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.cardListener = new OctoListener()
|
||||
this.cardListener.open([this.props.cardId], async (blockId) => {
|
||||
Utils.log(`cardListener.onChanged: ${blockId}`)
|
||||
await cardTree.sync()
|
||||
this.setState({cardTree})
|
||||
})
|
||||
const cardTree = new MutableCardTree(this.props.cardId)
|
||||
cardTree.sync().then(() => {
|
||||
this.setState({cardTree, title: cardTree.card.title})
|
||||
setTimeout(() => {
|
||||
if (this.titleRef.current) {
|
||||
this.titleRef.current.focus()
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.cardListener?.close()
|
||||
this.cardListener = undefined
|
||||
}
|
||||
|
||||
render() {
|
||||
const {boardTree, intl} = this.props
|
||||
const {cardTree} = this.state
|
||||
const {boardTree, cardTree, intl} = this.props
|
||||
const {board} = boardTree
|
||||
if (!cardTree) {
|
||||
return null
|
||||
@ -94,7 +71,7 @@ class CardDetail extends React.Component<Props, State> {
|
||||
key={block.id}
|
||||
block={block}
|
||||
cardId={card.id}
|
||||
cardTree={cardTree}
|
||||
contents={cardTree.contents}
|
||||
/>
|
||||
))}
|
||||
</div>)
|
||||
@ -110,7 +87,7 @@ class CardDetail extends React.Component<Props, State> {
|
||||
const block = new MutableTextBlock()
|
||||
block.parentId = card.id
|
||||
block.title = text
|
||||
block.order = cardTree.contents.length * 1000
|
||||
block.order = (this.props.cardTree.contents.length + 1) * 1000
|
||||
mutator.insertBlock(block, 'add card text')
|
||||
}
|
||||
}}
|
||||
@ -150,8 +127,13 @@ class CardDetail extends React.Component<Props, State> {
|
||||
value={this.state.title}
|
||||
placeholderText='Untitled'
|
||||
onChange={(title: string) => this.setState({title})}
|
||||
onSave={() => mutator.changeTitle(card, this.state.title)}
|
||||
onCancel={() => this.setState({title: this.state.cardTree.card.title})}
|
||||
saveOnEsc={true}
|
||||
onSave={() => {
|
||||
if (this.state.title !== this.props.cardTree.card.title) {
|
||||
mutator.changeTitle(card, this.state.title)
|
||||
}
|
||||
}}
|
||||
onCancel={() => this.setState({title: this.props.cardTree.card.title})}
|
||||
/>
|
||||
|
||||
{/* Property list */}
|
||||
@ -231,7 +213,7 @@ class CardDetail extends React.Component<Props, State> {
|
||||
onClick={() => {
|
||||
const block = new MutableTextBlock()
|
||||
block.parentId = card.id
|
||||
block.order = cardTree.contents.length * 1000
|
||||
block.order = (this.props.cardTree.contents.length + 1) * 1000
|
||||
mutator.insertBlock(block, 'add text')
|
||||
}}
|
||||
/>
|
||||
@ -239,7 +221,7 @@ class CardDetail extends React.Component<Props, State> {
|
||||
id='image'
|
||||
name={intl.formatMessage({id: 'CardDetail.image', defaultMessage: 'Image'})}
|
||||
onClick={() => Utils.selectLocalFile(
|
||||
(file) => mutator.createImageBlock(card.id, file, cardTree.contents.length * 1000),
|
||||
(file) => mutator.createImageBlock(card.id, file, (this.props.cardTree.contents.length + 1) * 1000),
|
||||
'.jpg,.jpeg,.png',
|
||||
)}
|
||||
/>
|
||||
|
@ -1,24 +1,79 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {FormattedMessage} from 'react-intl'
|
||||
|
||||
import {Card} from '../blocks/card'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import mutator from '../mutator'
|
||||
import Menu from '../widgets/menu'
|
||||
import {OctoListener} from '../octoListener'
|
||||
import {Utils} from '../utils'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import {CardTree, MutableCardTree} from '../viewModel/cardTree'
|
||||
import DeleteIcon from '../widgets/icons/delete'
|
||||
import Menu from '../widgets/menu'
|
||||
|
||||
import CardDetail from './cardDetail'
|
||||
import Dialog from './dialog'
|
||||
|
||||
type Props = {
|
||||
boardTree: BoardTree
|
||||
card: Card
|
||||
cardId: string
|
||||
onClose: () => void
|
||||
showCard: (cardId?: string) => void
|
||||
}
|
||||
|
||||
class CardDialog extends React.Component<Props> {
|
||||
render() {
|
||||
type State = {
|
||||
cardTree?: CardTree
|
||||
}
|
||||
|
||||
class CardDialog extends React.Component<Props, State> {
|
||||
state: State = {}
|
||||
|
||||
private cardListener?: OctoListener
|
||||
|
||||
shouldComponentUpdate(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.createCardTreeAndSync()
|
||||
}
|
||||
|
||||
private async createCardTreeAndSync() {
|
||||
const cardTree = new MutableCardTree(this.props.cardId)
|
||||
await cardTree.sync()
|
||||
this.createListener()
|
||||
this.setState({cardTree})
|
||||
Utils.log(`cardDialog.createCardTreeAndSync: ${cardTree.card.id}`)
|
||||
}
|
||||
|
||||
private createListener() {
|
||||
this.cardListener = new OctoListener()
|
||||
this.cardListener.open(
|
||||
[this.props.cardId],
|
||||
async (blocks) => {
|
||||
Utils.log(`cardListener.onChanged: ${blocks.length}`)
|
||||
const newCardTree = this.state.cardTree!.mutableCopy()
|
||||
if (newCardTree.incrementalUpdate(blocks)) {
|
||||
this.setState({cardTree: newCardTree})
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
Utils.log('cardListener.onReconnect')
|
||||
const newCardTree = this.state.cardTree!.mutableCopy()
|
||||
await newCardTree.sync()
|
||||
this.setState({cardTree: newCardTree})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this.cardListener?.close()
|
||||
this.cardListener = undefined
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
const {cardTree} = this.state
|
||||
|
||||
const menu = (
|
||||
<Menu position='left'>
|
||||
<Menu.Text
|
||||
@ -26,10 +81,22 @@ class CardDialog extends React.Component<Props> {
|
||||
icon={<DeleteIcon/>}
|
||||
name='Delete'
|
||||
onClick={async () => {
|
||||
await mutator.deleteBlock(this.props.card, 'delete card')
|
||||
const card = this.state.cardTree?.card
|
||||
if (!card) {
|
||||
Utils.assertFailure()
|
||||
return
|
||||
}
|
||||
await mutator.deleteBlock(card, 'delete card')
|
||||
this.props.onClose()
|
||||
}}
|
||||
/>
|
||||
{(cardTree && !cardTree.card.isTemplate) &&
|
||||
<Menu.Text
|
||||
id='makeTemplate'
|
||||
name='New template from card'
|
||||
onClick={this.makeTemplate}
|
||||
/>
|
||||
}
|
||||
</Menu>
|
||||
)
|
||||
return (
|
||||
@ -37,13 +104,49 @@ class CardDialog extends React.Component<Props> {
|
||||
onClose={this.props.onClose}
|
||||
toolsMenu={menu}
|
||||
>
|
||||
<CardDetail
|
||||
boardTree={this.props.boardTree}
|
||||
cardId={this.props.card.id}
|
||||
/>
|
||||
{(cardTree?.card.isTemplate) &&
|
||||
<div className='banner'>
|
||||
<FormattedMessage
|
||||
id='CardDialog.editing-template'
|
||||
defaultMessage="You're editing a template"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{this.state.cardTree &&
|
||||
<CardDetail
|
||||
boardTree={this.props.boardTree}
|
||||
cardTree={this.state.cardTree}
|
||||
/>
|
||||
}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
private makeTemplate = async () => {
|
||||
const {cardTree} = this.state
|
||||
if (!cardTree) {
|
||||
Utils.assertFailure('this.state.cardTree')
|
||||
return
|
||||
}
|
||||
|
||||
const newCardTree = cardTree.templateCopy()
|
||||
newCardTree.card.isTemplate = true
|
||||
newCardTree.card.title = 'New Template'
|
||||
|
||||
Utils.log(`Created new template: ${newCardTree.card.id}`)
|
||||
|
||||
const blocksToInsert = [newCardTree.card, ...newCardTree.contents]
|
||||
await mutator.insertBlocks(
|
||||
blocksToInsert,
|
||||
'create template from card',
|
||||
async () => {
|
||||
this.props.showCard(newCardTree.card.id)
|
||||
},
|
||||
async () => {
|
||||
this.props.showCard(undefined)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export {CardDialog}
|
||||
|
@ -1,19 +1,17 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {FC} from 'react'
|
||||
import {IntlShape, injectIntl} from 'react-intl'
|
||||
import {injectIntl, IntlShape} from 'react-intl'
|
||||
|
||||
import mutator from '../mutator'
|
||||
import {IBlock} from '../blocks/block'
|
||||
|
||||
import Menu from '../widgets/menu'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
import mutator from '../mutator'
|
||||
import {Utils} from '../utils'
|
||||
import IconButton from '../widgets/buttons/iconButton'
|
||||
import DeleteIcon from '../widgets/icons/delete'
|
||||
import OptionsIcon from '../widgets/icons/options'
|
||||
import IconButton from '../widgets/buttons/iconButton'
|
||||
|
||||
import Menu from '../widgets/menu'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
import './comment.scss'
|
||||
import {Utils} from '../utils'
|
||||
|
||||
type Props = {
|
||||
comment: IBlock
|
||||
|
@ -1,17 +1,15 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {injectIntl, IntlShape, FormattedMessage} from 'react-intl'
|
||||
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
|
||||
|
||||
import {MutableCommentBlock} from '../blocks/commentBlock'
|
||||
import {IBlock} from '../blocks/block'
|
||||
import {Utils} from '../utils'
|
||||
import {MutableCommentBlock} from '../blocks/commentBlock'
|
||||
import mutator from '../mutator'
|
||||
|
||||
import {Utils} from '../utils'
|
||||
import Button from '../widgets/buttons/button'
|
||||
|
||||
import Comment from './comment'
|
||||
|
||||
import './commentsList.scss'
|
||||
import {MarkdownEditor} from './markdownEditor'
|
||||
|
||||
@ -79,7 +77,7 @@ class CommentsList extends React.Component<Props, State> {
|
||||
text={this.state.newComment}
|
||||
placeholderText={intl.formatMessage({id: 'CardDetail.new-comment-placeholder', defaultMessage: 'Add a comment...'})}
|
||||
onChange={(value: string) => {
|
||||
if (this.state.newComment != value) {
|
||||
if (this.state.newComment !== value) {
|
||||
this.setState({newComment: value})
|
||||
}
|
||||
}}
|
||||
|
@ -3,47 +3,43 @@
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import {IOrderedBlock} from '../blocks/orderedBlock'
|
||||
import {CardTree} from '../viewModel/cardTree'
|
||||
import {OctoUtils} from '../octoUtils'
|
||||
import mutator from '../mutator'
|
||||
import {Utils} from '../utils'
|
||||
import {MutableTextBlock} from '../blocks/textBlock'
|
||||
import {MutableDividerBlock} from '../blocks/dividerBlock'
|
||||
|
||||
import {IOrderedBlock} from '../blocks/orderedBlock'
|
||||
import {MutableTextBlock} from '../blocks/textBlock'
|
||||
import mutator from '../mutator'
|
||||
import {OctoUtils} from '../octoUtils'
|
||||
import {Utils} from '../utils'
|
||||
import IconButton from '../widgets/buttons/iconButton'
|
||||
import AddIcon from '../widgets/icons/add'
|
||||
import DeleteIcon from '../widgets/icons/delete'
|
||||
import DividerIcon from '../widgets/icons/divider'
|
||||
import ImageIcon from '../widgets/icons/image'
|
||||
import OptionsIcon from '../widgets/icons/options'
|
||||
import SortDownIcon from '../widgets/icons/sortDown'
|
||||
import SortUpIcon from '../widgets/icons/sortUp'
|
||||
import TextIcon from '../widgets/icons/text'
|
||||
import Menu from '../widgets/menu'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
import OptionsIcon from '../widgets/icons/options'
|
||||
import SortUpIcon from '../widgets/icons/sortUp'
|
||||
import SortDownIcon from '../widgets/icons/sortDown'
|
||||
import DeleteIcon from '../widgets/icons/delete'
|
||||
import AddIcon from '../widgets/icons/add'
|
||||
import TextIcon from '../widgets/icons/text'
|
||||
import ImageIcon from '../widgets/icons/image'
|
||||
import DividerIcon from '../widgets/icons/divider'
|
||||
import IconButton from '../widgets/buttons/iconButton'
|
||||
|
||||
import {MarkdownEditor} from './markdownEditor'
|
||||
|
||||
import './contentBlock.scss'
|
||||
import {MarkdownEditor} from './markdownEditor'
|
||||
|
||||
type Props = {
|
||||
block: IOrderedBlock
|
||||
cardId: string
|
||||
cardTree: CardTree
|
||||
contents: readonly IOrderedBlock[]
|
||||
}
|
||||
|
||||
class ContentBlock extends React.Component<Props> {
|
||||
shouldComponentUpdate(): boolean {
|
||||
return true
|
||||
}
|
||||
class ContentBlock extends React.PureComponent<Props> {
|
||||
public render(): JSX.Element | null {
|
||||
const {cardId, contents, block} = this.props
|
||||
|
||||
public render(): JSX.Element {
|
||||
const {cardId, cardTree, block} = this.props
|
||||
if (block.type !== 'text' && block.type !== 'image' && block.type !== 'divider') {
|
||||
Utils.assertFailure(`Block type is unknown: ${block.type}`)
|
||||
return null
|
||||
}
|
||||
const index = cardTree.contents.indexOf(block)
|
||||
|
||||
const index = contents.indexOf(block)
|
||||
return (
|
||||
<div className='ContentBlock octo-block'>
|
||||
<div className='octo-block-margin'>
|
||||
@ -56,20 +52,20 @@ class ContentBlock extends React.Component<Props> {
|
||||
name='Move up'
|
||||
icon={<SortUpIcon/>}
|
||||
onClick={() => {
|
||||
const previousBlock = cardTree.contents[index - 1]
|
||||
const newOrder = OctoUtils.getOrderBefore(previousBlock, cardTree.contents)
|
||||
const previousBlock = contents[index - 1]
|
||||
const newOrder = OctoUtils.getOrderBefore(previousBlock, contents)
|
||||
Utils.log(`moveUp ${newOrder}`)
|
||||
mutator.changeOrder(block, newOrder, 'move up')
|
||||
}}
|
||||
/>}
|
||||
{index < (cardTree.contents.length - 1) &&
|
||||
{index < (contents.length - 1) &&
|
||||
<Menu.Text
|
||||
id='moveDown'
|
||||
name='Move down'
|
||||
icon={<SortDownIcon/>}
|
||||
onClick={() => {
|
||||
const nextBlock = cardTree.contents[index + 1]
|
||||
const newOrder = OctoUtils.getOrderAfter(nextBlock, cardTree.contents)
|
||||
const nextBlock = contents[index + 1]
|
||||
const newOrder = OctoUtils.getOrderAfter(nextBlock, contents)
|
||||
Utils.log(`moveDown ${newOrder}`)
|
||||
mutator.changeOrder(block, newOrder, 'move down')
|
||||
}}
|
||||
@ -88,7 +84,7 @@ class ContentBlock extends React.Component<Props> {
|
||||
newBlock.parentId = cardId
|
||||
|
||||
// TODO: Handle need to reorder all blocks
|
||||
newBlock.order = OctoUtils.getOrderBefore(block, cardTree.contents)
|
||||
newBlock.order = OctoUtils.getOrderBefore(block, contents)
|
||||
Utils.log(`insert block ${block.id}, order: ${block.order}`)
|
||||
mutator.insertBlock(newBlock, 'insert card text')
|
||||
}}
|
||||
@ -100,7 +96,7 @@ class ContentBlock extends React.Component<Props> {
|
||||
onClick={() => {
|
||||
Utils.selectLocalFile(
|
||||
(file) => {
|
||||
mutator.createImageBlock(cardId, file, OctoUtils.getOrderBefore(block, cardTree.contents))
|
||||
mutator.createImageBlock(cardId, file, OctoUtils.getOrderBefore(block, contents))
|
||||
},
|
||||
'.jpg,.jpeg,.png')
|
||||
}}
|
||||
@ -114,7 +110,7 @@ class ContentBlock extends React.Component<Props> {
|
||||
newBlock.parentId = cardId
|
||||
|
||||
// TODO: Handle need to reorder all blocks
|
||||
newBlock.order = OctoUtils.getOrderBefore(block, cardTree.contents)
|
||||
newBlock.order = OctoUtils.getOrderBefore(block, contents)
|
||||
Utils.log(`insert block ${block.id}, order: ${block.order}`)
|
||||
mutator.insertBlock(newBlock, 'insert card text')
|
||||
}}
|
||||
|
@ -24,6 +24,11 @@
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
> .banner {
|
||||
background-color: rgba(230, 220, 192, 0.9);
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
}
|
||||
> .toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
@ -19,10 +19,7 @@ type Props = {
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
}
|
||||
|
||||
class Editable extends React.Component<Props, State> {
|
||||
class Editable extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
text: '',
|
||||
isMarkdown: false,
|
||||
@ -30,46 +27,46 @@ class Editable extends React.Component<Props, State> {
|
||||
allowEmpty: true,
|
||||
}
|
||||
|
||||
private _text = ''
|
||||
private privateText = ''
|
||||
get text(): string {
|
||||
return this._text
|
||||
return this.privateText
|
||||
}
|
||||
set text(value: string) {
|
||||
const {isMarkdown} = this.props
|
||||
|
||||
if (!value) {
|
||||
this.elementRef.current.innerText = ''
|
||||
if (value) {
|
||||
this.elementRef.current!.innerHTML = isMarkdown ? Utils.htmlFromMarkdown(value) : Utils.htmlEncode(value)
|
||||
} else {
|
||||
this.elementRef.current.innerHTML = isMarkdown ? Utils.htmlFromMarkdown(value) : Utils.htmlEncode(value)
|
||||
this.elementRef.current!.innerText = ''
|
||||
}
|
||||
|
||||
this._text = value || ''
|
||||
this.privateText = value || ''
|
||||
}
|
||||
|
||||
private elementRef = React.createRef<HTMLDivElement>()
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this._text = props.text || ''
|
||||
this.privateText = props.text || ''
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this._text = this.props.text || ''
|
||||
componentDidUpdate(): void {
|
||||
this.privateText = this.props.text || ''
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.elementRef.current.focus()
|
||||
focus(): void {
|
||||
this.elementRef.current!.focus()
|
||||
|
||||
// Put cursor at end
|
||||
document.execCommand('selectAll', false, null)
|
||||
document.getSelection().collapseToEnd()
|
||||
document.execCommand('selectAll', false, undefined)
|
||||
document.getSelection()?.collapseToEnd()
|
||||
}
|
||||
|
||||
blur() {
|
||||
this.elementRef.current.blur()
|
||||
blur(): void {
|
||||
this.elementRef.current!.blur()
|
||||
}
|
||||
|
||||
render() {
|
||||
render(): JSX.Element {
|
||||
const {text, className, style, placeholderText, isMarkdown, isMultiline, onFocus, onBlur, onKeyDown, onChanged} = this.props
|
||||
|
||||
const initialStyle = {...this.props.style}
|
||||
@ -81,8 +78,8 @@ class Editable extends React.Component<Props, State> {
|
||||
html = ''
|
||||
}
|
||||
|
||||
const element =
|
||||
(<div
|
||||
const element = (
|
||||
<div
|
||||
ref={this.elementRef}
|
||||
className={'octo-editable ' + className}
|
||||
contentEditable={true}
|
||||
@ -93,9 +90,9 @@ class Editable extends React.Component<Props, State> {
|
||||
dangerouslySetInnerHTML={{__html: html}}
|
||||
|
||||
onFocus={() => {
|
||||
this.elementRef.current.innerText = this.text
|
||||
this.elementRef.current.style.color = style?.color || null
|
||||
this.elementRef.current.classList.add('active')
|
||||
this.elementRef.current!.innerText = this.text
|
||||
this.elementRef.current!.style!.color = style?.color || ''
|
||||
this.elementRef.current!.classList.add('active')
|
||||
|
||||
if (onFocus) {
|
||||
onFocus()
|
||||
@ -103,7 +100,7 @@ class Editable extends React.Component<Props, State> {
|
||||
}}
|
||||
|
||||
onBlur={async () => {
|
||||
const newText = this.elementRef.current.innerText
|
||||
const newText = this.elementRef.current!.innerText
|
||||
const oldText = this.props.text || ''
|
||||
if (this.props.allowEmpty || newText) {
|
||||
if (newText !== oldText && onChanged) {
|
||||
@ -115,7 +112,7 @@ class Editable extends React.Component<Props, State> {
|
||||
this.text = oldText // Reset text
|
||||
}
|
||||
|
||||
this.elementRef.current.classList.remove('active')
|
||||
this.elementRef.current!.classList.remove('active')
|
||||
if (onBlur) {
|
||||
onBlur()
|
||||
}
|
||||
@ -124,17 +121,17 @@ class Editable extends React.Component<Props, State> {
|
||||
onKeyDown={(e) => {
|
||||
if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC
|
||||
e.stopPropagation()
|
||||
this.elementRef.current.blur()
|
||||
this.elementRef.current!.blur()
|
||||
} else if (!isMultiline && e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Return
|
||||
e.stopPropagation()
|
||||
this.elementRef.current.blur()
|
||||
this.elementRef.current!.blur()
|
||||
}
|
||||
|
||||
if (onKeyDown) {
|
||||
onKeyDown(e)
|
||||
}
|
||||
}}
|
||||
/>);
|
||||
/>)
|
||||
|
||||
return element
|
||||
}
|
||||
|
@ -85,69 +85,70 @@ class FilterComponent extends React.Component<Props> {
|
||||
const propertyName = template ? template.name : '(unknown)' // TODO: Handle error
|
||||
const key = `${filter.propertyId}-${filter.condition}-${filter.values.join(',')}`
|
||||
Utils.log(`FilterClause key: ${key}`)
|
||||
return (<div
|
||||
className='octo-filterclause'
|
||||
key={key}
|
||||
>
|
||||
<MenuWrapper>
|
||||
<Button>{propertyName}</Button>
|
||||
<Menu>
|
||||
{board.cardProperties.filter((o) => o.type === 'select').map((o) => (
|
||||
return (
|
||||
<div
|
||||
className='octo-filterclause'
|
||||
key={key}
|
||||
>
|
||||
<MenuWrapper>
|
||||
<Button>{propertyName}</Button>
|
||||
<Menu>
|
||||
{board.cardProperties.filter((o) => o.type === 'select').map((o) => (
|
||||
<Menu.Text
|
||||
key={o.id}
|
||||
id={o.id}
|
||||
name={o.name}
|
||||
onClick={(optionId: string) => {
|
||||
const filterIndex = activeView.filter.filters.indexOf(filter)
|
||||
Utils.assert(filterIndex >= 0, "Can't find filter")
|
||||
const filterGroup = new FilterGroup(activeView.filter)
|
||||
const newFilter = filterGroup.filters[filterIndex] as FilterClause
|
||||
Utils.assert(newFilter, `No filter at index ${filterIndex}`)
|
||||
if (newFilter.propertyId !== optionId) {
|
||||
newFilter.propertyId = optionId
|
||||
newFilter.values = []
|
||||
mutator.changeViewFilter(activeView, filterGroup)
|
||||
}
|
||||
}}
|
||||
/>))}
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
<MenuWrapper>
|
||||
<Button>{FilterClause.filterConditionDisplayString(filter.condition, intl)}</Button>
|
||||
<Menu>
|
||||
<Menu.Text
|
||||
key={o.id}
|
||||
id={o.id}
|
||||
name={o.name}
|
||||
onClick={(optionId: string) => {
|
||||
const filterIndex = activeView.filter.filters.indexOf(filter)
|
||||
Utils.assert(filterIndex >= 0, "Can't find filter")
|
||||
const filterGroup = new FilterGroup(activeView.filter)
|
||||
const newFilter = filterGroup.filters[filterIndex] as FilterClause
|
||||
Utils.assert(newFilter, `No filter at index ${filterIndex}`)
|
||||
if (newFilter.propertyId !== optionId) {
|
||||
newFilter.propertyId = optionId
|
||||
newFilter.values = []
|
||||
mutator.changeViewFilter(activeView, filterGroup)
|
||||
}
|
||||
}}
|
||||
/>))}
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
<MenuWrapper>
|
||||
<Button>{FilterClause.filterConditionDisplayString(filter.condition, intl)}</Button>
|
||||
<Menu>
|
||||
<Menu.Text
|
||||
id='includes'
|
||||
name={intl.formatMessage({id: 'Filter.includes', defaultMessage: 'includes'})}
|
||||
onClick={(id) => this.conditionClicked(id, filter)}
|
||||
id='includes'
|
||||
name={intl.formatMessage({id: 'Filter.includes', defaultMessage: 'includes'})}
|
||||
onClick={(id) => this.conditionClicked(id, filter)}
|
||||
/>
|
||||
<Menu.Text
|
||||
id='notIncludes'
|
||||
name={intl.formatMessage({id: 'Filter.not-includes', defaultMessage: 'doesn\'t include'})}
|
||||
onClick={(id) => this.conditionClicked(id, filter)}
|
||||
/>
|
||||
<Menu.Text
|
||||
id='isEmpty'
|
||||
name={intl.formatMessage({id: 'Filter.is-empty', defaultMessage: 'is empty'})}
|
||||
onClick={(id) => this.conditionClicked(id, filter)}
|
||||
/>
|
||||
<Menu.Text
|
||||
id='isNotEmpty'
|
||||
name={intl.formatMessage({id: 'Filter.is-not-empty', defaultMessage: 'is not empty'})}
|
||||
onClick={(id) => this.conditionClicked(id, filter)}
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
{
|
||||
template && this.filterValue(filter, template)
|
||||
}
|
||||
<div className='octo-spacer'/>
|
||||
<Button onClick={() => this.deleteClicked(filter)}>
|
||||
<FormattedMessage
|
||||
id='FilterComponent.delete'
|
||||
defaultMessage='Delete'
|
||||
/>
|
||||
<Menu.Text
|
||||
id='notIncludes'
|
||||
name={intl.formatMessage({id: 'Filter.not-includes', defaultMessage: 'doesn\'t include'})}
|
||||
onClick={(id) => this.conditionClicked(id, filter)}
|
||||
/>
|
||||
<Menu.Text
|
||||
id='isEmpty'
|
||||
name={intl.formatMessage({id: 'Filter.is-empty', defaultMessage: 'is empty'})}
|
||||
onClick={(id) => this.conditionClicked(id, filter)}
|
||||
/>
|
||||
<Menu.Text
|
||||
id='isNotEmpty'
|
||||
name={intl.formatMessage({id: 'Filter.is-not-empty', defaultMessage: 'is not empty'})}
|
||||
onClick={(id) => this.conditionClicked(id, filter)}
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
{
|
||||
this.filterValue(filter, template)
|
||||
}
|
||||
<div className='octo-spacer'/>
|
||||
<Button onClick={() => this.deleteClicked(filter)}>
|
||||
<FormattedMessage
|
||||
id='FilterComponent.delete'
|
||||
defaultMessage='Delete'
|
||||
/>
|
||||
</Button>
|
||||
</div>)
|
||||
</Button>
|
||||
</div>)
|
||||
})}
|
||||
|
||||
<br/>
|
||||
@ -162,7 +163,7 @@ class FilterComponent extends React.Component<Props> {
|
||||
)
|
||||
}
|
||||
|
||||
private filterValue(filter: FilterClause, template: IPropertyTemplate): JSX.Element {
|
||||
private filterValue(filter: FilterClause, template: IPropertyTemplate): JSX.Element | undefined {
|
||||
const {boardTree} = this.props
|
||||
const {activeView: view} = boardTree
|
||||
|
||||
@ -177,10 +178,6 @@ class FilterComponent extends React.Component<Props> {
|
||||
displayValue = '(empty)'
|
||||
}
|
||||
|
||||
if (!template) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuWrapper>
|
||||
<Button>{displayValue}</Button>
|
||||
|
@ -26,7 +26,7 @@ type State = {
|
||||
}
|
||||
|
||||
export class FlashMessages extends React.PureComponent<Props, State> {
|
||||
private timeout: ReturnType<typeof setTimeout> = null
|
||||
private timeout?: ReturnType<typeof setTimeout>
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
@ -35,7 +35,7 @@ export class FlashMessages extends React.PureComponent<Props, State> {
|
||||
emitter.on('message', (message: FlashMessage) => {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout)
|
||||
this.timeout = null
|
||||
this.timeout = undefined
|
||||
}
|
||||
this.timeout = setTimeout(this.handleFadeOut, this.props.milliseconds - 200)
|
||||
this.setState({message})
|
||||
@ -48,16 +48,18 @@ export class FlashMessages extends React.PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
handleTimeout = (): void => {
|
||||
this.setState({message: null, fadeOut: false})
|
||||
this.setState({message: undefined, fadeOut: false})
|
||||
}
|
||||
|
||||
handleClick = (): void => {
|
||||
clearTimeout(this.timeout)
|
||||
this.timeout = null
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout)
|
||||
this.timeout = undefined
|
||||
}
|
||||
this.handleFadeOut()
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
public render(): JSX.Element | null {
|
||||
if (!this.state.message) {
|
||||
return null
|
||||
}
|
||||
|
@ -10,13 +10,16 @@ type Props = {
|
||||
}
|
||||
|
||||
type State = {
|
||||
isDragging?: boolean
|
||||
startX?: number
|
||||
offset?: number
|
||||
isDragging: boolean
|
||||
startX: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
class HorizontalGrip extends React.PureComponent<Props, State> {
|
||||
state: State = {
|
||||
isDragging: false,
|
||||
startX: 0,
|
||||
offset: 0,
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
|
@ -4,9 +4,8 @@ import EasyMDE from 'easymde'
|
||||
import React from 'react'
|
||||
import SimpleMDE from 'react-simplemde-editor'
|
||||
|
||||
import './markdownEditor.scss'
|
||||
|
||||
import {Utils} from '../utils'
|
||||
import './markdownEditor.scss'
|
||||
|
||||
type Props = {
|
||||
text?: string
|
||||
@ -29,13 +28,13 @@ class MarkdownEditor extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
get text(): string {
|
||||
return this.elementRef.current.state.value
|
||||
return this.elementRef.current!.state.value
|
||||
}
|
||||
set text(value: string) {
|
||||
this.elementRef.current.setState({value})
|
||||
this.elementRef.current!.setState({value})
|
||||
}
|
||||
|
||||
private editorInstance: EasyMDE
|
||||
private editorInstance?: EasyMDE
|
||||
private frameRef = React.createRef<HTMLDivElement>()
|
||||
private elementRef = React.createRef<SimpleMDE>()
|
||||
private previewRef = React.createRef<HTMLDivElement>()
|
||||
@ -45,14 +44,18 @@ class MarkdownEditor extends React.Component<Props, State> {
|
||||
this.state = {isEditing: false}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
shouldComponentUpdate(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
componentDidUpdate(): void {
|
||||
const newText = this.props.text || ''
|
||||
if (!this.state.isEditing && this.text !== newText) {
|
||||
this.text = newText
|
||||
}
|
||||
}
|
||||
|
||||
showEditor() {
|
||||
showEditor(): void {
|
||||
const cm = this.editorInstance?.codemirror
|
||||
if (cm) {
|
||||
setTimeout(() => {
|
||||
@ -66,12 +69,12 @@ class MarkdownEditor extends React.Component<Props, State> {
|
||||
this.setState({isEditing: true})
|
||||
}
|
||||
|
||||
hideEditor() {
|
||||
hideEditor(): void {
|
||||
this.editorInstance?.codemirror?.getInputField()?.blur()
|
||||
this.setState({isEditing: false})
|
||||
}
|
||||
|
||||
render() {
|
||||
render(): JSX.Element {
|
||||
const {text, placeholderText, uniqueId, onFocus, onBlur, onChange} = this.props
|
||||
|
||||
let html: string
|
||||
@ -81,21 +84,21 @@ class MarkdownEditor extends React.Component<Props, State> {
|
||||
html = Utils.htmlFromMarkdown(placeholderText || '')
|
||||
}
|
||||
|
||||
const previewElement =
|
||||
(<div
|
||||
ref={this.previewRef}
|
||||
className={text ? 'octo-editor-preview' : 'octo-editor-preview octo-placeholder'}
|
||||
style={{display: this.state.isEditing ? 'none' : null}}
|
||||
dangerouslySetInnerHTML={{__html: html}}
|
||||
onClick={() => {
|
||||
if (!this.state.isEditing) {
|
||||
this.showEditor()
|
||||
}
|
||||
}}
|
||||
/>);
|
||||
const previewElement = (
|
||||
<div
|
||||
ref={this.previewRef}
|
||||
className={text ? 'octo-editor-preview' : 'octo-editor-preview octo-placeholder'}
|
||||
style={{display: this.state.isEditing ? 'none' : undefined}}
|
||||
dangerouslySetInnerHTML={{__html: html}}
|
||||
onClick={() => {
|
||||
if (!this.state.isEditing) {
|
||||
this.showEditor()
|
||||
}
|
||||
}}
|
||||
/>)
|
||||
|
||||
const editorElement =
|
||||
(<div
|
||||
const editorElement = (
|
||||
<div
|
||||
className='octo-editor-activeEditor'
|
||||
|
||||
// Use visibility instead of display here so the editor is pre-rendered, avoiding a flash on showEditor
|
||||
@ -125,22 +128,22 @@ class MarkdownEditor extends React.Component<Props, State> {
|
||||
events={{
|
||||
change: () => {
|
||||
if (this.state.isEditing) {
|
||||
const newText = this.elementRef.current.state.value
|
||||
const newText = this.elementRef.current!.state.value
|
||||
onChange?.(newText)
|
||||
}
|
||||
},
|
||||
blur: () => {
|
||||
const newText = this.elementRef.current.state.value
|
||||
const newText = this.elementRef.current!.state.value
|
||||
const oldText = this.props.text || ''
|
||||
if (newText !== oldText && onChange) {
|
||||
const newHtml = newText ? Utils.htmlFromMarkdown(newText) : Utils.htmlFromMarkdown(placeholderText || '')
|
||||
this.previewRef.current.innerHTML = newHtml
|
||||
this.previewRef.current!.innerHTML = newHtml
|
||||
onChange(newText)
|
||||
}
|
||||
|
||||
this.text = newText
|
||||
|
||||
this.frameRef.current.classList.remove('active')
|
||||
this.frameRef.current!.classList.remove('active')
|
||||
|
||||
if (onBlur) {
|
||||
onBlur(newText)
|
||||
@ -149,9 +152,9 @@ class MarkdownEditor extends React.Component<Props, State> {
|
||||
this.hideEditor()
|
||||
},
|
||||
focus: () => {
|
||||
this.frameRef.current.classList.add('active')
|
||||
this.frameRef.current!.classList.add('active')
|
||||
|
||||
this.elementRef.current.setState({value: this.text})
|
||||
this.elementRef.current!.setState({value: this.text})
|
||||
|
||||
if (onFocus) {
|
||||
onFocus()
|
||||
@ -176,8 +179,8 @@ class MarkdownEditor extends React.Component<Props, State> {
|
||||
/>
|
||||
</div>)
|
||||
|
||||
const element =
|
||||
(<div
|
||||
const element = (
|
||||
<div
|
||||
ref={this.frameRef}
|
||||
className={`MarkdownEditor octo-editor ${this.props.className || ''}`}
|
||||
>
|
||||
|
@ -3,13 +3,12 @@
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import {IPropertyOption, IPropertyTemplate} from '../blocks/board'
|
||||
import {Card} from '../blocks/card'
|
||||
import {IPropertyTemplate, IPropertyOption} from '../blocks/board'
|
||||
import {OctoUtils} from '../octoUtils'
|
||||
import mutator from '../mutator'
|
||||
import {OctoUtils} from '../octoUtils'
|
||||
import {Utils} from '../utils'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
|
||||
import Editable from '../widgets/editable'
|
||||
import ValueSelector from '../widgets/valueSelector'
|
||||
|
||||
@ -56,7 +55,7 @@ export default class PropertyValueElement extends React.Component<Props, State>
|
||||
className += ' empty'
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
if (readOnly || !boardTree) {
|
||||
return (
|
||||
<div
|
||||
className={`${className} ${propertyColorCssClassName}`}
|
||||
@ -87,7 +86,7 @@ export default class PropertyValueElement extends React.Component<Props, State>
|
||||
value,
|
||||
color: 'propColorDefault',
|
||||
}
|
||||
await mutator.insertPropertyOption(this.props.boardTree, propertyTemplate, option, 'add property option')
|
||||
await mutator.insertPropertyOption(boardTree, propertyTemplate, option, 'add property option')
|
||||
mutator.changePropertyValue(card, propertyTemplate.id, option.id)
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
@ -21,21 +21,21 @@ export default class RootPortal extends React.PureComponent<Props> {
|
||||
this.el = document.createElement('div')
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
componentDidMount(): void {
|
||||
const rootPortal = document.getElementById('root-portal')
|
||||
if (rootPortal) {
|
||||
rootPortal.appendChild(this.el)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
componentWillUnmount(): void {
|
||||
const rootPortal = document.getElementById('root-portal')
|
||||
if (rootPortal) {
|
||||
rootPortal.removeChild(this.el)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
render(): JSX.Element {
|
||||
return ReactDOM.createPortal(
|
||||
this.props.children,
|
||||
this.el,
|
||||
|
@ -7,7 +7,8 @@
|
||||
min-height: 100%;
|
||||
color: rgb(var(--sidebar-fg));
|
||||
background-color: rgb(var(--sidebar-bg));
|
||||
padding: 10px 0;
|
||||
padding: 10px 0;
|
||||
overflow-y: scroll;
|
||||
|
||||
&.hidden {
|
||||
position: absolute;
|
||||
@ -25,6 +26,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
>* {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.octo-sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -94,11 +99,11 @@
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
.SubmenuTriangleIcon {
|
||||
.DisclosureTriangleIcon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
.SubmenuTriangleIcon {
|
||||
.DisclosureTriangleIcon {
|
||||
transition: 200ms ease-in-out;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
@ -107,10 +112,14 @@
|
||||
.octo-sidebar-title {
|
||||
cursor: pointer;
|
||||
flex-grow: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.OptionsIcon, .SubmenuTriangleIcon, .DotIcon {
|
||||
.OptionsIcon, .DisclosureTriangleIcon, .DotIcon {
|
||||
fill: rgba(var(--sidebar-fg), 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.HideSidebarIcon {
|
||||
|
@ -1,34 +1,33 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {injectIntl, IntlShape, FormattedMessage} from 'react-intl'
|
||||
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
|
||||
|
||||
import {Archiver} from '../archiver'
|
||||
import {mattermostTheme, darkTheme, lightTheme, setTheme} from '../theme'
|
||||
import {Board, MutableBoard} from '../blocks/board'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import {BoardView, MutableBoardView} from '../blocks/boardView'
|
||||
import mutator from '../mutator'
|
||||
import Menu from '../widgets/menu'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
import {darkTheme, lightTheme, mattermostTheme, setTheme} from '../theme'
|
||||
import {WorkspaceTree} from '../viewModel/workspaceTree'
|
||||
import Button from '../widgets/buttons/button'
|
||||
import IconButton from '../widgets/buttons/iconButton'
|
||||
import DeleteIcon from '../widgets/icons/delete'
|
||||
import DotIcon from '../widgets/icons/dot'
|
||||
import DuplicateIcon from '../widgets/icons/duplicate'
|
||||
import HamburgerIcon from '../widgets/icons/hamburger'
|
||||
import HideSidebarIcon from '../widgets/icons/hideSidebar'
|
||||
import OptionsIcon from '../widgets/icons/options'
|
||||
import ShowSidebarIcon from '../widgets/icons/showSidebar'
|
||||
import HideSidebarIcon from '../widgets/icons/hideSidebar'
|
||||
import HamburgerIcon from '../widgets/icons/hamburger'
|
||||
import DeleteIcon from '../widgets/icons/delete'
|
||||
import SubmenuTriangleIcon from '../widgets/icons/submenuTriangle'
|
||||
import DotIcon from '../widgets/icons/dot'
|
||||
import IconButton from '../widgets/buttons/iconButton'
|
||||
import Button from '../widgets/buttons/button'
|
||||
import {WorkspaceTree} from '../viewModel/workspaceTree'
|
||||
import {BoardView} from '../blocks/boardView'
|
||||
|
||||
import DisclosureTriangle from '../widgets/icons/disclosureTriangle'
|
||||
import Menu from '../widgets/menu'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
import './sidebar.scss'
|
||||
|
||||
type Props = {
|
||||
showBoard: (id: string) => void
|
||||
showView: (id: string, boardId?: string) => void
|
||||
workspaceTree: WorkspaceTree,
|
||||
boardTree?: BoardTree,
|
||||
activeBoardId?: string
|
||||
setLanguage: (lang: string) => void,
|
||||
intl: IntlShape
|
||||
}
|
||||
@ -90,18 +89,13 @@ class Sidebar extends React.Component<Props, State> {
|
||||
</div>
|
||||
{
|
||||
boards.map((board) => {
|
||||
const displayTitle = board.title || (
|
||||
<FormattedMessage
|
||||
id='Sidebar.untitled-board'
|
||||
defaultMessage='(Untitled Board)'
|
||||
/>
|
||||
)
|
||||
const displayTitle: string = board.title || intl.formatMessage({id: 'Sidebar.untitled-board', defaultMessage: '(Untitled Board)'})
|
||||
const boardViews = views.filter((view) => view.parentId === board.id)
|
||||
return (
|
||||
<div key={board.id}>
|
||||
<div className={'octo-sidebar-item ' + (collapsedBoards[board.id] ? 'collapsed' : 'expanded')}>
|
||||
<IconButton
|
||||
icon={<SubmenuTriangleIcon/>}
|
||||
icon={<DisclosureTriangle/>}
|
||||
onClick={() => {
|
||||
const newCollapsedBoards = {...this.state.collapsedBoards}
|
||||
newCollapsedBoards[board.id] = !newCollapsedBoards[board.id]
|
||||
@ -113,18 +107,19 @@ class Sidebar extends React.Component<Props, State> {
|
||||
onClick={() => {
|
||||
this.boardClicked(board)
|
||||
}}
|
||||
title={displayTitle}
|
||||
>
|
||||
{board.icon ? `${board.icon} ${displayTitle}` : displayTitle}
|
||||
</div>
|
||||
<MenuWrapper>
|
||||
<IconButton icon={<OptionsIcon/>}/>
|
||||
<Menu>
|
||||
<Menu position='left'>
|
||||
<Menu.Text
|
||||
id='delete'
|
||||
id='deleteBoard'
|
||||
name={intl.formatMessage({id: 'Sidebar.delete-board', defaultMessage: 'Delete Board'})}
|
||||
icon={<DeleteIcon/>}
|
||||
onClick={async () => {
|
||||
const nextBoardId = boards.length > 1 ? boards.find((o) => o.id !== board.id).id : undefined
|
||||
const nextBoardId = boards.length > 1 ? boards.find((o) => o.id !== board.id)?.id : undefined
|
||||
mutator.deleteBlock(
|
||||
board,
|
||||
'delete block',
|
||||
@ -137,6 +132,24 @@ class Sidebar extends React.Component<Props, State> {
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Menu.Text
|
||||
id='duplicateBoard'
|
||||
name={intl.formatMessage({id: 'Sidebar.duplicate-board', defaultMessage: 'Duplicate Board'})}
|
||||
icon={<DuplicateIcon/>}
|
||||
onClick={async () => {
|
||||
await mutator.duplicateBoard(
|
||||
board.id,
|
||||
'duplicate board',
|
||||
async (newBoardId) => {
|
||||
newBoardId && this.props.showBoard(newBoardId)
|
||||
},
|
||||
async () => {
|
||||
this.props.showBoard(board.id)
|
||||
},
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</div>
|
||||
@ -158,13 +171,9 @@ class Sidebar extends React.Component<Props, State> {
|
||||
onClick={() => {
|
||||
this.viewClicked(board, view)
|
||||
}}
|
||||
title={view.title || intl.formatMessage({id: 'Sidebar.untitled-view', defaultMessage: '(Untitled View)'})}
|
||||
>
|
||||
{view.title || (
|
||||
<FormattedMessage
|
||||
id='Sidebar.untitled-view'
|
||||
defaultMessage='(Untitled View)'
|
||||
/>
|
||||
)}
|
||||
{view.title || intl.formatMessage({id: 'Sidebar.untitled-view', defaultMessage: '(Untitled View)'})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -256,12 +265,17 @@ class Sidebar extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
private addBoardClicked = async () => {
|
||||
const {boardTree, showBoard} = this.props
|
||||
const {showBoard, intl} = this.props
|
||||
|
||||
const oldBoardId = boardTree?.board?.id
|
||||
const oldBoardId = this.props.activeBoardId
|
||||
const board = new MutableBoard()
|
||||
await mutator.insertBlock(
|
||||
board,
|
||||
const view = new MutableBoardView()
|
||||
view.viewType = 'board'
|
||||
view.parentId = board.id
|
||||
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board View'})
|
||||
|
||||
await mutator.insertBlocks(
|
||||
[board, view],
|
||||
'add board',
|
||||
async () => {
|
||||
showBoard(board.id)
|
||||
|
@ -3,44 +3,43 @@
|
||||
import React from 'react'
|
||||
import {FormattedMessage} from 'react-intl'
|
||||
|
||||
import {Constants} from '../constants'
|
||||
import {BlockIcons} from '../blockIcons'
|
||||
import {IBlock} from '../blocks/block'
|
||||
import {IPropertyTemplate} from '../blocks/board'
|
||||
import {Card, MutableCard} from '../blocks/card'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import {MutableBoardView} from '../blocks/boardView'
|
||||
import {MutableCard} from '../blocks/card'
|
||||
import {Constants} from '../constants'
|
||||
import mutator from '../mutator'
|
||||
import {Utils} from '../utils'
|
||||
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import {MutableCardTree} from '../viewModel/cardTree'
|
||||
import SortDownIcon from '../widgets/icons/sortDown'
|
||||
import SortUpIcon from '../widgets/icons/sortUp'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
|
||||
import {CardDialog} from './cardDialog'
|
||||
import {HorizontalGrip} from './horizontalGrip'
|
||||
import RootPortal from './rootPortal'
|
||||
import './tableComponent.scss'
|
||||
import TableHeaderMenu from './tableHeaderMenu'
|
||||
import {TableRow} from './tableRow'
|
||||
import ViewHeader from './viewHeader'
|
||||
import ViewTitle from './viewTitle'
|
||||
import TableHeaderMenu from './tableHeaderMenu'
|
||||
|
||||
import './tableComponent.scss'
|
||||
import {HorizontalGrip} from './horizontalGrip'
|
||||
|
||||
import {MutableBoardView} from '../blocks/boardView'
|
||||
|
||||
type Props = {
|
||||
boardTree?: BoardTree
|
||||
boardTree: BoardTree
|
||||
showView: (id: string) => void
|
||||
setSearchText: (text: string) => void
|
||||
setSearchText: (text?: string) => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
shownCard?: Card
|
||||
shownCardId?: string
|
||||
}
|
||||
|
||||
class TableComponent extends React.Component<Props, State> {
|
||||
private draggedHeaderTemplate: IPropertyTemplate
|
||||
private draggedHeaderTemplate?: IPropertyTemplate
|
||||
private cardIdToRowMap = new Map<string, React.RefObject<TableRow>>()
|
||||
private cardIdToFocusOnRender: string
|
||||
private cardIdToFocusOnRender?: string
|
||||
state: State = {}
|
||||
|
||||
shouldComponentUpdate(): boolean {
|
||||
@ -49,18 +48,6 @@ class TableComponent extends React.Component<Props, State> {
|
||||
|
||||
render(): JSX.Element {
|
||||
const {boardTree, showView} = this.props
|
||||
|
||||
if (!boardTree || !boardTree.board) {
|
||||
return (
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id='TableComponent.loading'
|
||||
defaultMessage='Loading...'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const {board, cards, activeView} = boardTree
|
||||
const titleRef = React.createRef<HTMLDivElement>()
|
||||
|
||||
@ -74,12 +61,14 @@ class TableComponent extends React.Component<Props, State> {
|
||||
|
||||
return (
|
||||
<div className='TableComponent octo-app'>
|
||||
{this.state.shownCard &&
|
||||
{this.state.shownCardId &&
|
||||
<RootPortal>
|
||||
<CardDialog
|
||||
key={this.state.shownCardId}
|
||||
boardTree={boardTree}
|
||||
card={this.state.shownCard}
|
||||
onClose={() => this.setState({shownCard: undefined})}
|
||||
cardId={this.state.shownCardId}
|
||||
onClose={() => this.setState({shownCardId: undefined})}
|
||||
showCard={(cardId) => this.setState({shownCardId: cardId})}
|
||||
/>
|
||||
</RootPortal>}
|
||||
<div className='octo-frame'>
|
||||
@ -93,7 +82,10 @@ class TableComponent extends React.Component<Props, State> {
|
||||
boardTree={boardTree}
|
||||
showView={showView}
|
||||
setSearchText={this.props.setSearchText}
|
||||
addCard={this.addCard}
|
||||
addCard={this.addCardAndShow}
|
||||
addCardFromTemplate={this.addCardFromTemplate}
|
||||
addCardTemplate={this.addCardTemplate}
|
||||
editCardTemplate={this.editCardTemplate}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
@ -135,13 +127,13 @@ class TableComponent extends React.Component<Props, State> {
|
||||
onDrag={(offset) => {
|
||||
const originalWidth = this.columnWidth(Constants.titleColumnId)
|
||||
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
|
||||
titleRef.current.style.width = `${newWidth}px`
|
||||
titleRef.current!.style!.width = `${newWidth}px`
|
||||
}}
|
||||
onDragEnd={(offset) => {
|
||||
Utils.log(`onDragEnd offset: ${offset}`)
|
||||
const originalWidth = this.columnWidth(Constants.titleColumnId)
|
||||
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
|
||||
titleRef.current.style.width = `${newWidth}px`
|
||||
titleRef.current!.style!.width = `${newWidth}px`
|
||||
|
||||
const columnWidths = {...activeView.columnWidths}
|
||||
if (newWidth !== columnWidths[Constants.titleColumnId]) {
|
||||
@ -167,78 +159,83 @@ class TableComponent extends React.Component<Props, State> {
|
||||
sortIcon = sortOption.reversed ? <SortUpIcon/> : <SortDownIcon/>
|
||||
}
|
||||
|
||||
return (<div
|
||||
key={template.id}
|
||||
ref={headerRef}
|
||||
style={{overflow: 'unset', width: this.columnWidth(template.id)}}
|
||||
className='octo-table-cell header-cell'
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
ref={headerRef}
|
||||
style={{overflow: 'unset', width: this.columnWidth(template.id)}}
|
||||
className='octo-table-cell header-cell'
|
||||
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault(); (e.target as HTMLElement).classList.add('dragover')
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault(); (e.target as HTMLElement).classList.add('dragover')
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault(); (e.target as HTMLElement).classList.remove('dragover')
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault(); (e.target as HTMLElement).classList.remove('dragover'); this.onDropToColumn(template)
|
||||
}}
|
||||
>
|
||||
<MenuWrapper>
|
||||
<div
|
||||
className='octo-label'
|
||||
style={{cursor: 'pointer'}}
|
||||
draggable={true}
|
||||
onDragStart={() => {
|
||||
this.draggedHeaderTemplate = template
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).classList.add('dragover')
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).classList.add('dragover')
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).classList.remove('dragover')
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).classList.remove('dragover')
|
||||
this.onDropToColumn(template)
|
||||
}}
|
||||
>
|
||||
<MenuWrapper>
|
||||
<div
|
||||
className='octo-label'
|
||||
style={{cursor: 'pointer'}}
|
||||
draggable={true}
|
||||
onDragStart={() => {
|
||||
this.draggedHeaderTemplate = template
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
this.draggedHeaderTemplate = undefined
|
||||
}}
|
||||
>
|
||||
{template.name}
|
||||
{sortIcon}
|
||||
</div>
|
||||
<TableHeaderMenu
|
||||
boardTree={boardTree}
|
||||
templateId={template.id}
|
||||
/>
|
||||
</MenuWrapper>
|
||||
|
||||
<div className='octo-spacer'/>
|
||||
|
||||
<HorizontalGrip
|
||||
onDrag={(offset) => {
|
||||
const originalWidth = this.columnWidth(template.id)
|
||||
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
|
||||
headerRef.current!.style.width = `${newWidth}px`
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
this.draggedHeaderTemplate = undefined
|
||||
onDragEnd={(offset) => {
|
||||
Utils.log(`onDragEnd offset: ${offset}`)
|
||||
const originalWidth = this.columnWidth(template.id)
|
||||
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
|
||||
headerRef.current!.style.width = `${newWidth}px`
|
||||
|
||||
const columnWidths = {...activeView.columnWidths}
|
||||
if (newWidth !== columnWidths[template.id]) {
|
||||
columnWidths[template.id] = newWidth
|
||||
|
||||
const newView = new MutableBoardView(activeView)
|
||||
newView.columnWidths = columnWidths
|
||||
mutator.updateBlock(newView, activeView, 'resize column')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{template.name}
|
||||
{sortIcon}
|
||||
</div>
|
||||
<TableHeaderMenu
|
||||
boardTree={boardTree}
|
||||
templateId={template.id}
|
||||
/>
|
||||
</MenuWrapper>
|
||||
|
||||
<div className='octo-spacer'/>
|
||||
|
||||
<HorizontalGrip
|
||||
onDrag={(offset) => {
|
||||
const originalWidth = this.columnWidth(template.id)
|
||||
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
|
||||
headerRef.current.style.width = `${newWidth}px`
|
||||
}}
|
||||
onDragEnd={(offset) => {
|
||||
Utils.log(`onDragEnd offset: ${offset}`)
|
||||
const originalWidth = this.columnWidth(template.id)
|
||||
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
|
||||
headerRef.current.style.width = `${newWidth}px`
|
||||
|
||||
const columnWidths = {...activeView.columnWidths}
|
||||
if (newWidth !== columnWidths[template.id]) {
|
||||
columnWidths[template.id] = newWidth
|
||||
|
||||
const newView = new MutableBoardView(activeView)
|
||||
newView.columnWidths = columnWidths
|
||||
mutator.updateBlock(newView, activeView, 'resize column')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>)
|
||||
</div>)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Rows, one per card */}
|
||||
|
||||
{cards.map((card) => {
|
||||
const openButonRef = React.createRef<HTMLDivElement>()
|
||||
const tableRowRef = React.createRef<TableRow>()
|
||||
|
||||
let focusOnMount = false
|
||||
@ -247,20 +244,22 @@ class TableComponent extends React.Component<Props, State> {
|
||||
focusOnMount = true
|
||||
}
|
||||
|
||||
const tableRow = (<TableRow
|
||||
key={card.id}
|
||||
ref={tableRowRef}
|
||||
boardTree={boardTree}
|
||||
card={card}
|
||||
focusOnMount={focusOnMount}
|
||||
onSaveWithEnter={() => {
|
||||
console.log('WORKING')
|
||||
if (cards.length > 0 && cards[cards.length - 1] === card) {
|
||||
this.addCard(false)
|
||||
}
|
||||
console.log('STILL WORKING')
|
||||
}}
|
||||
/>)
|
||||
const tableRow = (
|
||||
<TableRow
|
||||
key={card.id}
|
||||
ref={tableRowRef}
|
||||
boardTree={boardTree}
|
||||
card={card}
|
||||
focusOnMount={focusOnMount}
|
||||
onSaveWithEnter={() => {
|
||||
if (cards.length > 0 && cards[cards.length - 1] === card) {
|
||||
this.addCard(false)
|
||||
}
|
||||
}}
|
||||
showCard={(cardId) => {
|
||||
this.setState({shownCardId: cardId})
|
||||
}}
|
||||
/>)
|
||||
|
||||
this.cardIdToRowMap.set(card.id, tableRowRef)
|
||||
|
||||
@ -290,21 +289,43 @@ class TableComponent extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
private columnWidth(templateId: string): number {
|
||||
return Math.max(Constants.minColumnWidth, this.props.boardTree?.activeView?.columnWidths[templateId] || 0)
|
||||
return Math.max(Constants.minColumnWidth, this.props.boardTree.activeView.columnWidths[templateId] || 0)
|
||||
}
|
||||
|
||||
private addCard = async (show = false) => {
|
||||
private addCardAndShow = () => {
|
||||
this.addCard(true)
|
||||
}
|
||||
|
||||
private addCardFromTemplate = async (cardTemplateId?: string) => {
|
||||
this.addCard(true, cardTemplateId)
|
||||
}
|
||||
|
||||
private addCard = async (show = false, cardTemplateId?: string) => {
|
||||
const {boardTree} = this.props
|
||||
|
||||
const card = new MutableCard()
|
||||
let card: MutableCard
|
||||
let blocksToInsert: IBlock[]
|
||||
if (cardTemplateId) {
|
||||
const templateCardTree = new MutableCardTree(cardTemplateId)
|
||||
await templateCardTree.sync()
|
||||
const newCardTree = templateCardTree.templateCopy()
|
||||
card = newCardTree.card
|
||||
card.isTemplate = false
|
||||
card.title = ''
|
||||
blocksToInsert = [newCardTree.card, ...newCardTree.contents]
|
||||
} else {
|
||||
card = new MutableCard()
|
||||
blocksToInsert = [card]
|
||||
}
|
||||
|
||||
card.parentId = boardTree.board.id
|
||||
card.icon = BlockIcons.shared.randomIcon()
|
||||
await mutator.insertBlock(
|
||||
card,
|
||||
await mutator.insertBlocks(
|
||||
blocksToInsert,
|
||||
'add card',
|
||||
async () => {
|
||||
if (show) {
|
||||
this.setState({shownCard: card})
|
||||
this.setState({shownCardId: card.id})
|
||||
} else {
|
||||
// Focus on this card's title inline on next render
|
||||
this.cardIdToFocusOnRender = card.id
|
||||
@ -313,6 +334,27 @@ class TableComponent extends React.Component<Props, State> {
|
||||
)
|
||||
}
|
||||
|
||||
private addCardTemplate = async () => {
|
||||
const {boardTree} = this.props
|
||||
|
||||
const cardTemplate = new MutableCard()
|
||||
cardTemplate.isTemplate = true
|
||||
cardTemplate.parentId = boardTree.board.id
|
||||
await mutator.insertBlock(
|
||||
cardTemplate,
|
||||
'add card template',
|
||||
async () => {
|
||||
this.setState({shownCardId: cardTemplate.id})
|
||||
}, async () => {
|
||||
this.setState({shownCardId: undefined})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private editCardTemplate = (cardTemplateId: string) => {
|
||||
this.setState({shownCardId: cardTemplateId})
|
||||
}
|
||||
|
||||
private async onDropToColumn(template: IPropertyTemplate) {
|
||||
const {draggedHeaderTemplate} = this
|
||||
if (!draggedHeaderTemplate) {
|
||||
|
@ -5,7 +5,6 @@ import React, {FC} from 'react'
|
||||
import {injectIntl, IntlShape} from 'react-intl'
|
||||
|
||||
import {Constants} from '../constants'
|
||||
|
||||
import mutator from '../mutator'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import Menu from '../widgets/menu'
|
||||
|
@ -3,18 +3,14 @@
|
||||
import React from 'react'
|
||||
import {FormattedMessage} from 'react-intl'
|
||||
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import {Card} from '../blocks/card'
|
||||
import mutator from '../mutator'
|
||||
|
||||
import {Constants} from '../constants'
|
||||
import Editable from '../widgets/editable'
|
||||
import mutator from '../mutator'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import Button from '../widgets/buttons/button'
|
||||
import Editable from '../widgets/editable'
|
||||
|
||||
import PropertyValueElement from './propertyValueElement'
|
||||
import {CardDialog} from './cardDialog'
|
||||
import RootPortal from './rootPortal'
|
||||
|
||||
import './tableRow.scss'
|
||||
|
||||
type Props = {
|
||||
@ -22,10 +18,10 @@ type Props = {
|
||||
card: Card
|
||||
focusOnMount: boolean
|
||||
onSaveWithEnter: () => void
|
||||
showCard: (cardId: string) => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
showCard: boolean
|
||||
title: string
|
||||
}
|
||||
|
||||
@ -34,7 +30,6 @@ class TableRow extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
showCard: false,
|
||||
title: props.card.title,
|
||||
}
|
||||
}
|
||||
@ -45,7 +40,7 @@ class TableRow extends React.Component<Props, State> {
|
||||
|
||||
componentDidMount(): void {
|
||||
if (this.props.focusOnMount) {
|
||||
setTimeout(() => this.titleRef.current.focus(), 10)
|
||||
setTimeout(() => this.titleRef.current!.focus(), 10)
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,21 +79,13 @@ class TableRow extends React.Component<Props, State> {
|
||||
</div>
|
||||
|
||||
<div className='open-button'>
|
||||
<Button onClick={() => this.setState({showCard: true})}>
|
||||
<Button onClick={() => this.props.showCard(this.props.card.id)}>
|
||||
<FormattedMessage
|
||||
id='TableRow.open'
|
||||
defaultMessage='Open'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{this.state.showCard &&
|
||||
<RootPortal>
|
||||
<CardDialog
|
||||
boardTree={boardTree}
|
||||
card={card}
|
||||
onClose={() => this.setState({showCard: false})}
|
||||
/>
|
||||
</RootPortal>}
|
||||
</div>
|
||||
|
||||
{/* Columns, one per property */}
|
||||
@ -126,7 +113,7 @@ class TableRow extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
private columnWidth(templateId: string): number {
|
||||
return Math.max(Constants.minColumnWidth, this.props.boardTree?.activeView?.columnWidths[templateId] || 0)
|
||||
return Math.max(Constants.minColumnWidth, this.props.boardTree.activeView.columnWidths[templateId] || 0)
|
||||
}
|
||||
|
||||
focusOnTitle(): void {
|
||||
|
@ -1,43 +1,43 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {injectIntl, IntlShape, FormattedMessage} from 'react-intl'
|
||||
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
|
||||
|
||||
import {Archiver} from '../archiver'
|
||||
import {ISortOption, MutableBoardView} from '../blocks/boardView'
|
||||
import {BlockIcons} from '../blockIcons'
|
||||
import {MutableCard} from '../blocks/card'
|
||||
import {IPropertyTemplate} from '../blocks/board'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import ViewMenu from '../components/viewMenu'
|
||||
import {CsvExporter} from '../csvExporter'
|
||||
import {ISortOption, MutableBoardView} from '../blocks/boardView'
|
||||
import {MutableCard} from '../blocks/card'
|
||||
import {CardFilter} from '../cardFilter'
|
||||
import ViewMenu from '../components/viewMenu'
|
||||
import {Constants} from '../constants'
|
||||
import {CsvExporter} from '../csvExporter'
|
||||
import mutator from '../mutator'
|
||||
import {Utils} from '../utils'
|
||||
import Menu from '../widgets/menu'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
import CheckIcon from '../widgets/icons/check'
|
||||
import DropdownIcon from '../widgets/icons/dropdown'
|
||||
import OptionsIcon from '../widgets/icons/options'
|
||||
import SortUpIcon from '../widgets/icons/sortUp'
|
||||
import SortDownIcon from '../widgets/icons/sortDown'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import Button from '../widgets/buttons/button'
|
||||
import ButtonWithMenu from '../widgets/buttons/buttonWithMenu'
|
||||
import IconButton from '../widgets/buttons/iconButton'
|
||||
import Button from '../widgets/buttons/button'
|
||||
import CheckIcon from '../widgets/icons/check'
|
||||
import DeleteIcon from '../widgets/icons/delete'
|
||||
import DropdownIcon from '../widgets/icons/dropdown'
|
||||
import OptionsIcon from '../widgets/icons/options'
|
||||
import SortDownIcon from '../widgets/icons/sortDown'
|
||||
import SortUpIcon from '../widgets/icons/sortUp'
|
||||
import Menu from '../widgets/menu'
|
||||
import MenuWrapper from '../widgets/menuWrapper'
|
||||
|
||||
import {Editable} from './editable'
|
||||
import FilterComponent from './filterComponent'
|
||||
|
||||
import './viewHeader.scss'
|
||||
import {sendFlashMessage} from './flashMessages'
|
||||
|
||||
import {Constants} from '../constants'
|
||||
|
||||
type Props = {
|
||||
boardTree?: BoardTree
|
||||
boardTree: BoardTree
|
||||
showView: (id: string) => void
|
||||
setSearchText: (text: string) => void
|
||||
addCard: (show: boolean) => void
|
||||
setSearchText: (text?: string) => void
|
||||
addCard: () => void
|
||||
addCardFromTemplate: (cardTemplateId?: string) => void
|
||||
addCardTemplate: () => void
|
||||
editCardTemplate: (cardTemplateId: string) => void
|
||||
withGroupBy?: boolean
|
||||
intl: IntlShape
|
||||
}
|
||||
@ -56,12 +56,12 @@ class ViewHeader extends React.Component<Props, State> {
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {isSearching: Boolean(this.props.boardTree?.getSearchText()), showFilter: false}
|
||||
this.state = {isSearching: Boolean(this.props.boardTree.getSearchText()), showFilter: false}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevPros: Props, prevState: State): void {
|
||||
if (this.state.isSearching && !prevState.isSearching) {
|
||||
this.searchFieldRef.current.focus()
|
||||
this.searchFieldRef.current!.focus()
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,7 +75,7 @@ class ViewHeader extends React.Component<Props, State> {
|
||||
|
||||
private onSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.keyCode === 27) { // ESC: Clear search
|
||||
this.searchFieldRef.current.text = ''
|
||||
this.searchFieldRef.current!.text = ''
|
||||
this.setState({isSearching: false})
|
||||
this.props.setSearchText(undefined)
|
||||
e.preventDefault()
|
||||
@ -90,10 +90,10 @@ class ViewHeader extends React.Component<Props, State> {
|
||||
const {boardTree} = this.props
|
||||
const {board, activeView} = boardTree
|
||||
|
||||
const startCount = boardTree?.cards?.length
|
||||
const startCount = boardTree.cards.length
|
||||
let optionIndex = 0
|
||||
|
||||
await mutator.performAsUndoGroup(async () => {
|
||||
mutator.performAsUndoGroup(async () => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const card = new MutableCard()
|
||||
card.parentId = boardTree.board.id
|
||||
@ -107,7 +107,26 @@ class ViewHeader extends React.Component<Props, State> {
|
||||
optionIndex = (optionIndex + 1) % boardTree.groupByProperty.options.length
|
||||
card.properties[boardTree.groupByProperty.id] = option.id
|
||||
}
|
||||
await mutator.insertBlock(card, 'test add card')
|
||||
mutator.insertBlock(card, 'test add card')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async testDistributeCards() {
|
||||
const {boardTree} = this.props
|
||||
mutator.performAsUndoGroup(async () => {
|
||||
let optionIndex = 0
|
||||
for (const card of boardTree.cards) {
|
||||
if (boardTree.groupByProperty && boardTree.groupByProperty.options.length > 0) {
|
||||
// Cycle through options
|
||||
const option = boardTree.groupByProperty.options[optionIndex]
|
||||
optionIndex = (optionIndex + 1) % boardTree.groupByProperty.options.length
|
||||
const newCard = new MutableCard(card)
|
||||
if (newCard.properties[boardTree.groupByProperty.id] !== option.id) {
|
||||
newCard.properties[boardTree.groupByProperty.id] = option.id
|
||||
mutator.updateBlock(newCard, card, 'test distribute cards')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -115,9 +134,9 @@ class ViewHeader extends React.Component<Props, State> {
|
||||
private async testRandomizeIcons() {
|
||||
const {boardTree} = this.props
|
||||
|
||||
await mutator.performAsUndoGroup(async () => {
|
||||
mutator.performAsUndoGroup(async () => {
|
||||
for (const card of boardTree.cards) {
|
||||
await mutator.changeIcon(card, BlockIcons.shared.randomIcon(), 'randomize icon')
|
||||
mutator.changeIcon(card, BlockIcons.shared.randomIcon(), 'randomize icon')
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -163,10 +182,6 @@ class ViewHeader extends React.Component<Props, State> {
|
||||
name={option.name}
|
||||
isOn={activeView.visiblePropertyIds.includes(option.id)}
|
||||
onClick={(propertyId: string) => {
|
||||
const property = boardTree.board.cardProperties.find((o: IPropertyTemplate) => o.id === propertyId)
|
||||
Utils.assertValue(property)
|
||||
Utils.log(`Toggle property ${property.name}`)
|
||||
|
||||
let newVisiblePropertyIds = []
|
||||
if (activeView.visiblePropertyIds.includes(propertyId)) {
|
||||
newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o: string) => o !== propertyId)
|
||||
@ -266,28 +281,37 @@ class ViewHeader extends React.Component<Props, State> {
|
||||
</>
|
||||
}
|
||||
|
||||
{this.sortDisplayOptions().map((option) => (
|
||||
<Menu.Text
|
||||
key={option.id}
|
||||
id={option.id}
|
||||
name={option.name}
|
||||
rightIcon={(activeView.sortOptions[0]?.propertyId === option.id) ? activeView.sortOptions[0].reversed ? <SortUpIcon/> : <SortDownIcon/> : undefined}
|
||||
onClick={(propertyId: string) => {
|
||||
let newSortOptions: ISortOption[] = []
|
||||
if (activeView.sortOptions[0] && activeView.sortOptions[0].propertyId === propertyId) {
|
||||
{this.sortDisplayOptions().map((option) => {
|
||||
let rightIcon: JSX.Element | undefined
|
||||
if (activeView.sortOptions.length > 0) {
|
||||
const sortOption = activeView.sortOptions[0]
|
||||
if (sortOption.propertyId === option.id) {
|
||||
rightIcon = sortOption.reversed ? <SortUpIcon/> : <SortDownIcon/>
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Menu.Text
|
||||
key={option.id}
|
||||
id={option.id}
|
||||
name={option.name}
|
||||
rightIcon={rightIcon}
|
||||
onClick={(propertyId: string) => {
|
||||
let newSortOptions: ISortOption[] = []
|
||||
if (activeView.sortOptions[0] && activeView.sortOptions[0].propertyId === propertyId) {
|
||||
// Already sorting by name, so reverse it
|
||||
newSortOptions = [
|
||||
{propertyId, reversed: !activeView.sortOptions[0].reversed},
|
||||
]
|
||||
} else {
|
||||
newSortOptions = [
|
||||
{propertyId, reversed: false},
|
||||
]
|
||||
}
|
||||
mutator.changeViewSortOptions(activeView, newSortOptions)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
newSortOptions = [
|
||||
{propertyId, reversed: !activeView.sortOptions[0].reversed},
|
||||
]
|
||||
} else {
|
||||
newSortOptions = [
|
||||
{propertyId, reversed: false},
|
||||
]
|
||||
}
|
||||
mutator.changeViewSortOptions(activeView, newSortOptions)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
{this.state.isSearching &&
|
||||
@ -323,6 +347,9 @@ class ViewHeader extends React.Component<Props, State> {
|
||||
name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export Board Archive'})}
|
||||
onClick={() => Archiver.exportBoardTree(boardTree)}
|
||||
/>
|
||||
|
||||
<Menu.Separator/>
|
||||
|
||||
<Menu.Text
|
||||
id='testAdd100Cards'
|
||||
name={intl.formatMessage({id: 'ViewHeader.test-add-100-cards', defaultMessage: 'TEST: Add 100 cards'})}
|
||||
@ -333,6 +360,11 @@ class ViewHeader extends React.Component<Props, State> {
|
||||
name={intl.formatMessage({id: 'ViewHeader.test-add-1000-cards', defaultMessage: 'TEST: Add 1,000 cards'})}
|
||||
onClick={() => this.testAddCards(1000)}
|
||||
/>
|
||||
<Menu.Text
|
||||
id='testDistributeCards'
|
||||
name={intl.formatMessage({id: 'ViewHeader.test-distribute-cards', defaultMessage: 'TEST: Distribute cards'})}
|
||||
onClick={() => this.testDistributeCards()}
|
||||
/>
|
||||
<Menu.Text
|
||||
id='testRandomizeIcons'
|
||||
name={intl.formatMessage({id: 'ViewHeader.test-randomize-icons', defaultMessage: 'TEST: Randomize icons'})}
|
||||
@ -340,9 +372,10 @@ class ViewHeader extends React.Component<Props, State> {
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
|
||||
<ButtonWithMenu
|
||||
onClick={() => {
|
||||
this.props.addCard(true)
|
||||
this.props.addCard()
|
||||
}}
|
||||
text={(
|
||||
<FormattedMessage
|
||||
@ -360,10 +393,56 @@ class ViewHeader extends React.Component<Props, State> {
|
||||
/>
|
||||
</b>
|
||||
</Menu.Label>
|
||||
|
||||
<Menu.Separator/>
|
||||
|
||||
{boardTree.cardTemplates.map((cardTemplate) => {
|
||||
return (
|
||||
<Menu.Text
|
||||
key={cardTemplate.id}
|
||||
id={cardTemplate.id}
|
||||
name={cardTemplate.title || intl.formatMessage({id: 'ViewHeader.untitled', defaultMessage: 'Untitled'})}
|
||||
onClick={() => {
|
||||
this.props.addCardFromTemplate(cardTemplate.id)
|
||||
}}
|
||||
rightIcon={
|
||||
<MenuWrapper stopPropagationOnToggle={true}>
|
||||
<IconButton icon={<OptionsIcon/>}/>
|
||||
<Menu position='left'>
|
||||
<Menu.Text
|
||||
id='edit'
|
||||
name={intl.formatMessage({id: 'ViewHeader.edit-template', defaultMessage: 'Edit'})}
|
||||
onClick={() => {
|
||||
this.props.editCardTemplate(cardTemplate.id)
|
||||
}}
|
||||
/>
|
||||
<Menu.Text
|
||||
icon={<DeleteIcon/>}
|
||||
id='delete'
|
||||
name={intl.formatMessage({id: 'ViewHeader.delete-template', defaultMessage: 'Delete'})}
|
||||
onClick={async () => {
|
||||
await mutator.deleteBlock(cardTemplate, 'delete card template')
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<Menu.Text
|
||||
id='example-template'
|
||||
name={intl.formatMessage({id: 'ViewHeader.sample-templte', defaultMessage: 'Sample template'})}
|
||||
onClick={() => sendFlashMessage({content: 'Not implemented yet', severity: 'low'})}
|
||||
id='empty-template'
|
||||
name={intl.formatMessage({id: 'ViewHeader.empty-card', defaultMessage: 'Empty card'})}
|
||||
onClick={() => {
|
||||
this.props.addCard()
|
||||
}}
|
||||
/>
|
||||
|
||||
<Menu.Text
|
||||
id='add-template'
|
||||
name={intl.formatMessage({id: 'ViewHeader.add-template', defaultMessage: '+ New template'})}
|
||||
onClick={() => this.props.addCardTemplate()}
|
||||
/>
|
||||
</Menu>
|
||||
</ButtonWithMenu>
|
||||
|
@ -1,43 +1,50 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {injectIntl, IntlShape} from 'react-intl'
|
||||
|
||||
import {Board} from '../blocks/board'
|
||||
import {MutableBoardView} from '../blocks/boardView'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import {Constants} from '../constants'
|
||||
import mutator from '../mutator'
|
||||
import {Utils} from '../utils'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import Menu from '../widgets/menu'
|
||||
import {Constants} from '../constants'
|
||||
|
||||
type Props = {
|
||||
boardTree?: BoardTree
|
||||
boardTree: BoardTree
|
||||
board: Board,
|
||||
showView: (id: string) => void
|
||||
intl: IntlShape
|
||||
}
|
||||
|
||||
export default class ViewMenu extends React.Component<Props> {
|
||||
handleDeleteView = async () => {
|
||||
export class ViewMenu extends React.PureComponent<Props> {
|
||||
private handleDeleteView = async () => {
|
||||
const {boardTree, showView} = this.props
|
||||
Utils.log('deleteView')
|
||||
const view = boardTree.activeView
|
||||
const nextView = boardTree.views.find((o) => o !== view)
|
||||
await mutator.deleteBlock(view, 'delete view')
|
||||
showView(nextView.id)
|
||||
if (nextView) {
|
||||
showView(nextView.id)
|
||||
}
|
||||
}
|
||||
|
||||
handleViewClick = (id: string) => {
|
||||
private handleViewClick = (id: string) => {
|
||||
const {boardTree, showView} = this.props
|
||||
Utils.log('view ' + id)
|
||||
const view = boardTree.views.find((o) => o.id === id)
|
||||
showView(view.id)
|
||||
Utils.assert(view, `view not found: ${id}`)
|
||||
if (view) {
|
||||
showView(view.id)
|
||||
}
|
||||
}
|
||||
|
||||
handleAddViewBoard = async () => {
|
||||
const {board, boardTree, showView} = this.props
|
||||
private handleAddViewBoard = async () => {
|
||||
const {board, boardTree, showView, intl} = this.props
|
||||
Utils.log('addview-board')
|
||||
const view = new MutableBoardView()
|
||||
view.title = 'Board View'
|
||||
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board View'})
|
||||
view.viewType = 'board'
|
||||
view.parentId = board.id
|
||||
|
||||
@ -54,12 +61,12 @@ export default class ViewMenu extends React.Component<Props> {
|
||||
})
|
||||
}
|
||||
|
||||
handleAddViewTable = async () => {
|
||||
const {board, boardTree, showView} = this.props
|
||||
private handleAddViewTable = async () => {
|
||||
const {board, boardTree, showView, intl} = this.props
|
||||
|
||||
Utils.log('addview-table')
|
||||
const view = new MutableBoardView()
|
||||
view.title = 'Table View'
|
||||
view.title = intl.formatMessage({id: 'View.NewTableTitle', defaultMessage: 'Table View'})
|
||||
view.viewType = 'table'
|
||||
view.parentId = board.id
|
||||
view.visiblePropertyIds = board.cardProperties.map((o) => o.id)
|
||||
@ -79,7 +86,7 @@ export default class ViewMenu extends React.Component<Props> {
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
render(): JSX.Element {
|
||||
const {boardTree} = this.props
|
||||
return (
|
||||
<Menu>
|
||||
@ -91,11 +98,12 @@ export default class ViewMenu extends React.Component<Props> {
|
||||
onClick={this.handleViewClick}
|
||||
/>))}
|
||||
<Menu.Separator/>
|
||||
{boardTree.views.length > 1 && <Menu.Text
|
||||
id='__deleteView'
|
||||
name='Delete View'
|
||||
onClick={this.handleDeleteView}
|
||||
/>}
|
||||
{boardTree.views.length > 1 &&
|
||||
<Menu.Text
|
||||
id='__deleteView'
|
||||
name='Delete View'
|
||||
onClick={this.handleDeleteView}
|
||||
/>}
|
||||
<Menu.SubMenu
|
||||
id='__addView'
|
||||
name='Add View'
|
||||
@ -115,3 +123,5 @@ export default class ViewMenu extends React.Component<Props> {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(ViewMenu)
|
||||
|
@ -26,5 +26,6 @@
|
||||
|
||||
.Editable {
|
||||
margin-bottom: 0px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,16 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {injectIntl, IntlShape, FormattedMessage} from 'react-intl'
|
||||
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
|
||||
|
||||
import {BlockIcons} from '../blockIcons'
|
||||
import {Board} from '../blocks/board'
|
||||
import mutator from '../mutator'
|
||||
import Editable from '../widgets/editable'
|
||||
import Button from '../widgets/buttons/button'
|
||||
import Editable from '../widgets/editable'
|
||||
import EmojiIcon from '../widgets/icons/emoji'
|
||||
|
||||
import BlockIconSelector from './blockIconSelector'
|
||||
|
||||
import './viewTitle.scss'
|
||||
|
||||
type Props = {
|
||||
@ -62,6 +61,7 @@ class ViewTitle extends React.Component<Props, State> {
|
||||
value={this.state.title}
|
||||
placeholderText={intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled Board'})}
|
||||
onChange={(title) => this.setState({title})}
|
||||
saveOnEsc={true}
|
||||
onSave={() => mutator.changeTitle(board, this.state.title)}
|
||||
onCancel={() => this.setState({title: this.props.board.title})}
|
||||
/>
|
||||
|
@ -2,14 +2,13 @@
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import {Utils} from '../utils'
|
||||
import {BoardTree} from '../viewModel/boardTree'
|
||||
import {WorkspaceTree} from '../viewModel/workspaceTree'
|
||||
|
||||
import BoardComponent from './boardComponent'
|
||||
import Sidebar from './sidebar'
|
||||
import {TableComponent} from './tableComponent'
|
||||
|
||||
import './workspaceComponent.scss'
|
||||
|
||||
type Props = {
|
||||
@ -17,22 +16,22 @@ type Props = {
|
||||
boardTree?: BoardTree
|
||||
showBoard: (id: string) => void
|
||||
showView: (id: string, boardId?: string) => void
|
||||
setSearchText: (text: string) => void
|
||||
setSearchText: (text?: string) => void
|
||||
setLanguage: (lang: string) => void
|
||||
}
|
||||
|
||||
class WorkspaceComponent extends React.Component<Props> {
|
||||
render() {
|
||||
class WorkspaceComponent extends React.PureComponent<Props> {
|
||||
render(): JSX.Element {
|
||||
const {boardTree, workspaceTree, showBoard, showView, setLanguage} = this.props
|
||||
|
||||
Utils.assert(workspaceTree)
|
||||
const element =
|
||||
(<div className='WorkspaceComponent'>
|
||||
const element = (
|
||||
<div className='WorkspaceComponent'>
|
||||
<Sidebar
|
||||
showBoard={showBoard}
|
||||
showView={showView}
|
||||
workspaceTree={workspaceTree}
|
||||
boardTree={boardTree}
|
||||
activeBoardId={boardTree?.board.id}
|
||||
setLanguage={setLanguage}
|
||||
/>
|
||||
{this.mainComponent()}
|
||||
@ -45,25 +44,27 @@ class WorkspaceComponent extends React.Component<Props> {
|
||||
const {boardTree, setSearchText, showView} = this.props
|
||||
const {activeView} = boardTree || {}
|
||||
|
||||
if (!activeView) {
|
||||
if (!boardTree || !activeView) {
|
||||
return <div/>
|
||||
}
|
||||
|
||||
switch (activeView?.viewType) {
|
||||
switch (activeView.viewType) {
|
||||
case 'board': {
|
||||
return (<BoardComponent
|
||||
boardTree={boardTree}
|
||||
setSearchText={setSearchText}
|
||||
showView={showView}
|
||||
/>)
|
||||
return (
|
||||
<BoardComponent
|
||||
boardTree={boardTree}
|
||||
setSearchText={setSearchText}
|
||||
showView={showView}
|
||||
/>)
|
||||
}
|
||||
|
||||
case 'table': {
|
||||
return (<TableComponent
|
||||
boardTree={boardTree}
|
||||
setSearchText={setSearchText}
|
||||
showView={showView}
|
||||
/>)
|
||||
return (
|
||||
<TableComponent
|
||||
boardTree={boardTree}
|
||||
setSearchText={setSearchText}
|
||||
showView={showView}
|
||||
/>)
|
||||
}
|
||||
|
||||
default: {
|
||||
|
@ -10,7 +10,11 @@ class CsvExporter {
|
||||
const {activeView} = boardTree
|
||||
const viewToExport = view ?? activeView
|
||||
|
||||
const rows = CsvExporter.generateTableArray(boardTree, view)
|
||||
if (!viewToExport) {
|
||||
return
|
||||
}
|
||||
|
||||
const rows = CsvExporter.generateTableArray(boardTree, viewToExport)
|
||||
|
||||
let csvContent = 'data:text/csv;charset=utf-8,'
|
||||
|
||||
@ -19,7 +23,7 @@ class CsvExporter {
|
||||
csvContent += encodedRow + '\r\n'
|
||||
})
|
||||
|
||||
const filename = `${Utils.sanitizeFilename(viewToExport.title)}.csv`
|
||||
const filename = `${Utils.sanitizeFilename(viewToExport.title || 'Untitled')}.csv`
|
||||
const encodedUri = encodeURI(csvContent)
|
||||
const link = document.createElement('a')
|
||||
link.style.display = 'none'
|
||||
@ -32,9 +36,8 @@ class CsvExporter {
|
||||
// TODO: Remove or reuse link
|
||||
}
|
||||
|
||||
private static generateTableArray(boardTree: BoardTree, view?: BoardView): string[][] {
|
||||
const {board, cards, activeView} = boardTree
|
||||
const viewToExport = view ?? activeView
|
||||
private static generateTableArray(boardTree: BoardTree, viewToExport: BoardView): string[][] {
|
||||
const {board, cards} = boardTree
|
||||
|
||||
const rows: string[][] = []
|
||||
const visibleProperties = board.cardProperties.filter((template) => viewToExport.visiblePropertyIds.includes(template.id))
|
||||
@ -54,7 +57,7 @@ class CsvExporter {
|
||||
const propertyValue = card.properties[template.id]
|
||||
const displayValue = OctoUtils.propertyDisplayValue(card, propertyValue, template) || ''
|
||||
if (template.type === 'number') {
|
||||
const numericValue = propertyValue ? Number(propertyValue).toString() : undefined
|
||||
const numericValue = propertyValue ? Number(propertyValue).toString() : ''
|
||||
row.push(numericValue)
|
||||
} else {
|
||||
// Export as string
|
||||
|
@ -11,6 +11,7 @@ import {FilterGroup} from './filterGroup'
|
||||
import octoClient from './octoClient'
|
||||
import undoManager from './undomanager'
|
||||
import {Utils} from './utils'
|
||||
import {OctoUtils} from './octoUtils'
|
||||
|
||||
//
|
||||
// The Mutator is used to make all changes to server state
|
||||
@ -19,10 +20,10 @@ import {Utils} from './utils'
|
||||
class Mutator {
|
||||
private undoGroupId?: string
|
||||
|
||||
private beginUndoGroup(): string {
|
||||
private beginUndoGroup(): string | undefined {
|
||||
if (this.undoGroupId) {
|
||||
Utils.assertFailure('UndoManager does not support nested groups')
|
||||
return
|
||||
return undefined
|
||||
}
|
||||
this.undoGroupId = Utils.createGuid()
|
||||
return this.undoGroupId
|
||||
@ -43,7 +44,9 @@ class Mutator {
|
||||
} catch (err) {
|
||||
Utils.assertFailure(`ERROR: ${err?.toString?.()}`)
|
||||
}
|
||||
this.endUndoGroup(groupId)
|
||||
if (groupId) {
|
||||
this.endUndoGroup(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
async updateBlock(newBlock: IBlock, oldBlock: IBlock, description: string): Promise<void> {
|
||||
@ -95,9 +98,11 @@ class Mutator {
|
||||
},
|
||||
async () => {
|
||||
await beforeUndo?.()
|
||||
const awaits = []
|
||||
for (const block of blocks) {
|
||||
await octoClient.deleteBlock(block.id)
|
||||
awaits.push(octoClient.deleteBlock(block.id))
|
||||
}
|
||||
await Promise.all(awaits)
|
||||
},
|
||||
description,
|
||||
this.undoGroupId,
|
||||
@ -105,9 +110,7 @@ class Mutator {
|
||||
}
|
||||
|
||||
async deleteBlock(block: IBlock, description?: string, beforeRedo?: () => Promise<void>, afterUndo?: () => Promise<void>) {
|
||||
if (!description) {
|
||||
description = `delete ${block.type}`
|
||||
}
|
||||
const actualDescription = description || `delete ${block.type}`
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
@ -118,7 +121,7 @@ class Mutator {
|
||||
await octoClient.insertBlock(block)
|
||||
await afterUndo?.()
|
||||
},
|
||||
description,
|
||||
actualDescription,
|
||||
this.undoGroupId,
|
||||
)
|
||||
}
|
||||
@ -144,6 +147,10 @@ class Mutator {
|
||||
newBlock = board
|
||||
break
|
||||
}
|
||||
default: {
|
||||
Utils.assertFailure(`changeIcon: Invalid block type: ${block.type}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateBlock(newBlock, block, description)
|
||||
@ -159,24 +166,23 @@ class Mutator {
|
||||
|
||||
async insertPropertyTemplate(boardTree: BoardTree, index = -1, template?: IPropertyTemplate) {
|
||||
const {board, activeView} = boardTree
|
||||
|
||||
if (index < 0) {
|
||||
index = board.cardProperties.length
|
||||
if (!activeView) {
|
||||
Utils.assertFailure('insertPropertyTemplate: no activeView')
|
||||
return
|
||||
}
|
||||
|
||||
if (!template) {
|
||||
template = {
|
||||
id: Utils.createGuid(),
|
||||
name: 'New Property',
|
||||
type: 'text',
|
||||
options: [],
|
||||
}
|
||||
const newTemplate = template || {
|
||||
id: Utils.createGuid(),
|
||||
name: 'New Property',
|
||||
type: 'text',
|
||||
options: [],
|
||||
}
|
||||
|
||||
const oldBlocks: IBlock[] = [board]
|
||||
|
||||
const newBoard = new MutableBoard(board)
|
||||
newBoard.cardProperties.splice(index, 0, template)
|
||||
const startIndex = (index >= 0) ? index : board.cardProperties.length
|
||||
newBoard.cardProperties.splice(startIndex, 0, newTemplate)
|
||||
const changedBlocks: IBlock[] = [newBoard]
|
||||
|
||||
let description = 'add property'
|
||||
@ -185,7 +191,7 @@ class Mutator {
|
||||
oldBlocks.push(activeView)
|
||||
|
||||
const newActiveView = new MutableBoardView(activeView)
|
||||
newActiveView.visiblePropertyIds.push(template.id)
|
||||
newActiveView.visiblePropertyIds.push(newTemplate.id)
|
||||
changedBlocks.push(newActiveView)
|
||||
|
||||
description = 'add column'
|
||||
@ -196,6 +202,10 @@ class Mutator {
|
||||
|
||||
async duplicatePropertyTemplate(boardTree: BoardTree, propertyId: string) {
|
||||
const {board, activeView} = boardTree
|
||||
if (!activeView) {
|
||||
Utils.assertFailure('duplicatePropertyTemplate: no activeView')
|
||||
return
|
||||
}
|
||||
|
||||
const oldBlocks: IBlock[] = [board]
|
||||
|
||||
@ -296,7 +306,7 @@ class Mutator {
|
||||
Utils.assert(board.cardProperties.includes(template))
|
||||
|
||||
const newBoard = new MutableBoard(board)
|
||||
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)
|
||||
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)!
|
||||
newTemplate.options.push(option)
|
||||
|
||||
await this.updateBlock(newBoard, board, description)
|
||||
@ -306,7 +316,7 @@ class Mutator {
|
||||
const {board} = boardTree
|
||||
|
||||
const newBoard = new MutableBoard(board)
|
||||
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)
|
||||
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)!
|
||||
newTemplate.options = newTemplate.options.filter((o) => o.id !== option.id)
|
||||
|
||||
await this.updateBlock(newBoard, board, 'delete option')
|
||||
@ -317,20 +327,20 @@ class Mutator {
|
||||
Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`)
|
||||
|
||||
const newBoard = new MutableBoard(board)
|
||||
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)
|
||||
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)!
|
||||
newTemplate.options.splice(destIndex, 0, newTemplate.options.splice(srcIndex, 1)[0])
|
||||
|
||||
await this.updateBlock(newBoard, board, 'reorder options')
|
||||
}
|
||||
|
||||
async changePropertyOptionValue(boardTree: BoardTree, propertyTemplate: IPropertyTemplate, option: IPropertyOption, value: string) {
|
||||
const {board, cards} = boardTree
|
||||
const {board} = boardTree
|
||||
|
||||
const oldBlocks: IBlock[] = [board]
|
||||
|
||||
const newBoard = new MutableBoard(board)
|
||||
const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)
|
||||
const newOption = newTemplate.options.find((o) => o.id === option.id)
|
||||
const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)!
|
||||
const newOption = newTemplate.options.find((o) => o.id === option.id)!
|
||||
newOption.value = value
|
||||
const changedBlocks: IBlock[] = [newBoard]
|
||||
|
||||
@ -341,15 +351,19 @@ class Mutator {
|
||||
|
||||
async changePropertyOptionColor(board: Board, template: IPropertyTemplate, option: IPropertyOption, color: string) {
|
||||
const newBoard = new MutableBoard(board)
|
||||
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)
|
||||
const newOption = newTemplate.options.find((o) => o.id === option.id)
|
||||
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)!
|
||||
const newOption = newTemplate.options.find((o) => o.id === option.id)!
|
||||
newOption.color = color
|
||||
await this.updateBlock(newBoard, board, 'change option color')
|
||||
}
|
||||
|
||||
async changePropertyValue(card: Card, propertyId: string, value?: string, description = 'change property') {
|
||||
const newCard = new MutableCard(card)
|
||||
newCard.properties[propertyId] = value
|
||||
if (value) {
|
||||
newCard.properties[propertyId] = value
|
||||
} else {
|
||||
delete newCard.properties[propertyId]
|
||||
}
|
||||
await this.updateBlock(newCard, card, description)
|
||||
}
|
||||
|
||||
@ -357,7 +371,7 @@ class Mutator {
|
||||
const {board} = boardTree
|
||||
|
||||
const newBoard = new MutableBoard(board)
|
||||
const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)
|
||||
const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)!
|
||||
newTemplate.type = type
|
||||
|
||||
const oldBlocks: IBlock[] = [board]
|
||||
@ -369,7 +383,11 @@ class Mutator {
|
||||
if (oldValue) {
|
||||
const newValue = propertyTemplate.options.find((o) => o.id === oldValue)?.value
|
||||
const newCard = new MutableCard(card)
|
||||
newCard.properties[propertyTemplate.id] = newValue
|
||||
if (newValue) {
|
||||
newCard.properties[propertyTemplate.id] = newValue
|
||||
} else {
|
||||
delete newCard.properties[propertyTemplate.id]
|
||||
}
|
||||
newBlocks.push(newCard)
|
||||
oldBlocks.push(card)
|
||||
}
|
||||
@ -381,7 +399,11 @@ class Mutator {
|
||||
if (oldValue) {
|
||||
const newValue = propertyTemplate.options.find((o) => o.value === oldValue)?.id
|
||||
const newCard = new MutableCard(card)
|
||||
newCard.properties[propertyTemplate.id] = newValue
|
||||
if (newValue) {
|
||||
newCard.properties[propertyTemplate.id] = newValue
|
||||
} else {
|
||||
delete newCard.properties[propertyTemplate.id]
|
||||
}
|
||||
newBlocks.push(newCard)
|
||||
oldBlocks.push(card)
|
||||
}
|
||||
@ -399,7 +421,7 @@ class Mutator {
|
||||
await this.updateBlock(newView, view, 'sort')
|
||||
}
|
||||
|
||||
async changeViewFilter(view: BoardView, filter?: FilterGroup): Promise<void> {
|
||||
async changeViewFilter(view: BoardView, filter: FilterGroup): Promise<void> {
|
||||
const newView = new MutableBoardView(view)
|
||||
newView.filter = filter
|
||||
await this.updateBlock(newView, view, 'filter')
|
||||
@ -460,6 +482,46 @@ class Mutator {
|
||||
await this.updateBlock(newView, view, description)
|
||||
}
|
||||
|
||||
// Duplicate
|
||||
|
||||
async duplicateCard(cardId: string, description = 'duplicate card', afterRedo?: (newBoardId: string) => Promise<void>, beforeUndo?: () => Promise<void>): Promise<[IBlock[], string]> {
|
||||
const blocks = await octoClient.getSubtree(cardId, 2)
|
||||
const [newBlocks1, idMap] = OctoUtils.duplicateBlockTree(blocks, cardId)
|
||||
const newBlocks = newBlocks1.filter((o) => o.type !== 'comment')
|
||||
Utils.log(`duplicateCard: duplicating ${newBlocks.length} blocks`)
|
||||
const newCardId = idMap[cardId]
|
||||
const newCard = newBlocks.find((o) => o.id === newCardId)!
|
||||
newCard.title = `Copy of ${newCard.title}`
|
||||
await this.insertBlocks(
|
||||
newBlocks,
|
||||
description,
|
||||
async () => {
|
||||
await afterRedo?.(newCardId)
|
||||
},
|
||||
beforeUndo,
|
||||
)
|
||||
return [newBlocks, newCardId]
|
||||
}
|
||||
|
||||
async duplicateBoard(boardId: string, description = 'duplicate board', afterRedo?: (newBoardId: string) => Promise<void>, beforeUndo?: () => Promise<void>): Promise<[IBlock[], string]> {
|
||||
const blocks = await octoClient.getSubtree(boardId, 3)
|
||||
const [newBlocks1, idMap] = OctoUtils.duplicateBlockTree(blocks, boardId)
|
||||
const newBlocks = newBlocks1.filter((o) => o.type !== 'comment')
|
||||
Utils.log(`duplicateBoard: duplicating ${newBlocks.length} blocks`)
|
||||
const newBoardId = idMap[boardId]
|
||||
const newBoard = newBlocks.find((o) => o.id === newBoardId)!
|
||||
newBoard.title = `Copy of ${newBoard.title}`
|
||||
await this.insertBlocks(
|
||||
newBlocks,
|
||||
description,
|
||||
async () => {
|
||||
await afterRedo?.(newBoardId)
|
||||
},
|
||||
beforeUndo,
|
||||
)
|
||||
return [newBlocks, newBoardId]
|
||||
}
|
||||
|
||||
// Other methods
|
||||
|
||||
// Not a mutator, but convenient to put here since Mutator wraps OctoClient
|
||||
|
@ -14,8 +14,8 @@ class OctoClient {
|
||||
Utils.log(`OctoClient serverUrl: ${this.serverUrl}`)
|
||||
}
|
||||
|
||||
async getSubtree(rootId?: string): Promise<IBlock[]> {
|
||||
const path = `/api/v1/blocks/${rootId}/subtree`
|
||||
async getSubtree(rootId?: string, levels = 2): Promise<IBlock[]> {
|
||||
const path = `/api/v1/blocks/${rootId}/subtree?l=${levels}`
|
||||
const response = await fetch(this.serverUrl + path)
|
||||
const blocks = (await response.json() || []) as IMutableBlock[]
|
||||
this.fixBlocks(blocks)
|
||||
@ -36,7 +36,7 @@ class OctoClient {
|
||||
Utils.log(`\t ${block.type}, ${block.id}`)
|
||||
})
|
||||
const body = JSON.stringify(blocks)
|
||||
return await fetch(this.serverUrl + '/api/v1/blocks/import', {
|
||||
return fetch(this.serverUrl + '/api/v1/blocks/import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@ -68,35 +68,22 @@ class OctoClient {
|
||||
return blocks
|
||||
}
|
||||
|
||||
// TODO: Remove this fixup code
|
||||
fixBlocks(blocks: IMutableBlock[]): void {
|
||||
if (!blocks) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO
|
||||
for (const block of blocks) {
|
||||
if (!block.fields) {
|
||||
block.fields = {}
|
||||
}
|
||||
const o = block as any
|
||||
if (o.cardProperties) {
|
||||
block.fields.cardProperties = o.cardProperties; delete o.cardProperties
|
||||
}
|
||||
if (o.properties) {
|
||||
block.fields.properties = o.properties; delete o.properties
|
||||
}
|
||||
if (o.icon) {
|
||||
block.fields.icon = o.icon; delete o.icon
|
||||
}
|
||||
if (o.url) {
|
||||
block.fields.url = o.url; delete o.url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateBlock(block: IMutableBlock): Promise<Response> {
|
||||
block.updateAt = Date.now()
|
||||
return await this.insertBlocks([block])
|
||||
return this.insertBlocks([block])
|
||||
}
|
||||
|
||||
async updateBlocks(blocks: IMutableBlock[]): Promise<Response> {
|
||||
@ -104,12 +91,12 @@ class OctoClient {
|
||||
blocks.forEach((block) => {
|
||||
block.updateAt = now
|
||||
})
|
||||
return await this.insertBlocks(blocks)
|
||||
return this.insertBlocks(blocks)
|
||||
}
|
||||
|
||||
async deleteBlock(blockId: string): Promise<Response> {
|
||||
Utils.log(`deleteBlock: ${blockId}`)
|
||||
return await fetch(this.serverUrl + `/api/v1/blocks/${encodeURIComponent(blockId)}`, {
|
||||
return fetch(this.serverUrl + `/api/v1/blocks/${encodeURIComponent(blockId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@ -125,10 +112,10 @@ class OctoClient {
|
||||
async insertBlocks(blocks: IBlock[]): Promise<Response> {
|
||||
Utils.log(`insertBlocks: ${blocks.length} blocks(s)`)
|
||||
blocks.forEach((block) => {
|
||||
Utils.log(`\t ${block.type}, ${block.id}`)
|
||||
Utils.log(`\t ${block.type}, ${block.id}, ${block.title?.substr(0, 50) || ''}`)
|
||||
})
|
||||
const body = JSON.stringify(blocks)
|
||||
return await fetch(this.serverUrl + '/api/v1/blocks', {
|
||||
return fetch(this.serverUrl + '/api/v1/blocks', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
|
@ -1,5 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {IBlock} from './blocks/block'
|
||||
import {Utils} from './utils'
|
||||
|
||||
// These are outgoing commands to the server
|
||||
@ -12,14 +13,17 @@ type WSCommand = {
|
||||
type WSMessage = {
|
||||
action: string
|
||||
blockId: string
|
||||
block: IBlock
|
||||
}
|
||||
|
||||
type OnChangeHandler = (blocks: IBlock[]) => void
|
||||
|
||||
//
|
||||
// OctoListener calls a handler when a block or any of its children changes
|
||||
//
|
||||
class OctoListener {
|
||||
get isOpen(): boolean {
|
||||
return this.ws !== undefined
|
||||
return Boolean(this.ws)
|
||||
}
|
||||
|
||||
readonly serverUrl: string
|
||||
@ -27,7 +31,11 @@ class OctoListener {
|
||||
private blockIds: string[] = []
|
||||
private isInitialized = false
|
||||
|
||||
notificationDelay = 200
|
||||
private onChange?: OnChangeHandler
|
||||
private updatedBlocks: IBlock[] = []
|
||||
private updateTimeout?: NodeJS.Timeout
|
||||
|
||||
notificationDelay = 100
|
||||
reopenDelay = 3000
|
||||
|
||||
constructor(serverUrl?: string) {
|
||||
@ -35,13 +43,15 @@ class OctoListener {
|
||||
Utils.log(`OctoListener serverUrl: ${this.serverUrl}`)
|
||||
}
|
||||
|
||||
open(blockIds: string[], onChange: (blockId: string) => void) {
|
||||
open(blockIds: string[], onChange: OnChangeHandler, onReconnect: () => void): void {
|
||||
let timeoutId: NodeJS.Timeout
|
||||
|
||||
if (this.ws) {
|
||||
this.close()
|
||||
}
|
||||
|
||||
this.onChange = onChange
|
||||
|
||||
const url = new URL(this.serverUrl)
|
||||
const wsServerUrl = `ws://${url.host}${url.pathname}ws/onchange`
|
||||
Utils.log(`OctoListener open: ${wsServerUrl}`)
|
||||
@ -65,13 +75,14 @@ class OctoListener {
|
||||
const reopenBlockIds = this.isInitialized ? this.blockIds.slice() : blockIds.slice()
|
||||
Utils.logError(`Unexpected close, re-opening with ${reopenBlockIds.length} blocks...`)
|
||||
setTimeout(() => {
|
||||
this.open(reopenBlockIds, onChange)
|
||||
this.open(reopenBlockIds, onChange, onReconnect)
|
||||
onReconnect()
|
||||
}, this.reopenDelay)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
Utils.log(`OctoListener websocket onmessage. data: ${e.data}`)
|
||||
// Utils.log(`OctoListener websocket onmessage. data: ${e.data}`)
|
||||
if (ws !== this.ws) {
|
||||
Utils.log('Ignoring closed ws')
|
||||
return
|
||||
@ -84,10 +95,8 @@ class OctoListener {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = undefined
|
||||
onChange(message.blockId)
|
||||
}, this.notificationDelay)
|
||||
Utils.log(`OctoListener update block: ${message.block?.id}`)
|
||||
this.queueUpdateNotification(message.block)
|
||||
break
|
||||
default:
|
||||
Utils.logError(`Unexpected action: ${message.action}`)
|
||||
@ -98,7 +107,7 @@ class OctoListener {
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
close(): void {
|
||||
if (!this.ws) {
|
||||
return
|
||||
}
|
||||
@ -109,12 +118,13 @@ class OctoListener {
|
||||
const ws = this.ws
|
||||
this.ws = undefined
|
||||
this.blockIds = []
|
||||
this.onChange = undefined
|
||||
this.isInitialized = false
|
||||
ws.close()
|
||||
}
|
||||
|
||||
addBlocks(blockIds: string[]): void {
|
||||
if (!this.isOpen) {
|
||||
if (!this.ws) {
|
||||
Utils.assertFailure('OctoListener.addBlocks: ws is not open')
|
||||
return
|
||||
}
|
||||
@ -129,7 +139,7 @@ class OctoListener {
|
||||
}
|
||||
|
||||
removeBlocks(blockIds: string[]): void {
|
||||
if (!this.isOpen) {
|
||||
if (!this.ws) {
|
||||
Utils.assertFailure('OctoListener.removeBlocks: ws is not open')
|
||||
return
|
||||
}
|
||||
@ -151,6 +161,24 @@ class OctoListener {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private queueUpdateNotification(block: IBlock) {
|
||||
this.updatedBlocks = this.updatedBlocks.filter((o) => o.id !== block.id) // Remove existing queued update
|
||||
this.updatedBlocks.push(block)
|
||||
if (this.updateTimeout) {
|
||||
clearTimeout(this.updateTimeout)
|
||||
this.updateTimeout = undefined
|
||||
}
|
||||
|
||||
this.updateTimeout = setTimeout(() => {
|
||||
this.flushUpdateNotifications()
|
||||
}, this.notificationDelay)
|
||||
}
|
||||
|
||||
private flushUpdateNotifications() {
|
||||
this.onChange?.(this.updatedBlocks)
|
||||
this.updatedBlocks = []
|
||||
}
|
||||
}
|
||||
|
||||
export {OctoListener}
|
||||
|
@ -1,21 +1,19 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
|
||||
import {IBlock, MutableBlock} from './blocks/block'
|
||||
import {IPropertyTemplate, MutableBoard} from './blocks/board'
|
||||
import {MutableBoardView} from './blocks/boardView'
|
||||
import {MutableCard} from './blocks/card'
|
||||
import {MutableCommentBlock} from './blocks/commentBlock'
|
||||
import {MutableImageBlock} from './blocks/imageBlock'
|
||||
import {MutableDividerBlock} from './blocks/dividerBlock'
|
||||
import {MutableImageBlock} from './blocks/imageBlock'
|
||||
import {IOrderedBlock} from './blocks/orderedBlock'
|
||||
import {MutableTextBlock} from './blocks/textBlock'
|
||||
import {Utils} from './utils'
|
||||
|
||||
class OctoUtils {
|
||||
static propertyDisplayValue(block: IBlock, propertyValue: string | undefined, propertyTemplate: IPropertyTemplate): string | undefined {
|
||||
let displayValue: string
|
||||
let displayValue: string | undefined
|
||||
switch (propertyTemplate.type) {
|
||||
case 'select': {
|
||||
// The property value is the id of the template
|
||||
@ -80,6 +78,42 @@ class OctoUtils {
|
||||
static hydrateBlocks(blocks: IBlock[]): MutableBlock[] {
|
||||
return blocks.map((block) => this.hydrateBlock(block))
|
||||
}
|
||||
|
||||
static mergeBlocks(blocks: IBlock[], updatedBlocks: IBlock[]): IBlock[] {
|
||||
const updatedBlockIds = updatedBlocks.map((o) => o.id)
|
||||
const newBlocks = blocks.filter((o) => !updatedBlockIds.includes(o.id))
|
||||
const updatedAndNotDeletedBlocks = updatedBlocks.filter((o) => o.deleteAt === 0)
|
||||
newBlocks.push(...updatedAndNotDeletedBlocks)
|
||||
return newBlocks
|
||||
}
|
||||
|
||||
// Creates a copy of the blocks with new ids and parentIDs
|
||||
static duplicateBlockTree(blocks: IBlock[], rootBlockId?: string): [MutableBlock[], Readonly<Record<string, string>>] {
|
||||
const idMap: Record<string, string> = {}
|
||||
const newBlocks = blocks.map((block) => {
|
||||
const newBlock = this.hydrateBlock(block)
|
||||
newBlock.id = Utils.createGuid()
|
||||
idMap[block.id] = newBlock.id
|
||||
return newBlock
|
||||
})
|
||||
|
||||
const newRootBlockId = rootBlockId ? idMap[rootBlockId] : undefined
|
||||
newBlocks.forEach((newBlock) => {
|
||||
// Note: Don't remap the parent of the new root block
|
||||
if (newBlock.id !== newRootBlockId && newBlock.parentId) {
|
||||
newBlock.parentId = idMap[newBlock.parentId] || newBlock.parentId
|
||||
Utils.assert(newBlock.parentId, `Block ${newBlock.id} (${newBlock.type} ${newBlock.title}) has no parent`)
|
||||
}
|
||||
|
||||
// Remap manual card order
|
||||
if (newBlock.type === 'view') {
|
||||
const view = newBlock as MutableBoardView
|
||||
view.cardOrder = view.cardOrder.map((o) => idMap[o])
|
||||
}
|
||||
})
|
||||
|
||||
return [newBlocks, idMap]
|
||||
}
|
||||
}
|
||||
|
||||
export {OctoUtils}
|
||||
|
@ -2,14 +2,14 @@
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
|
||||
import {BoardView} from '../blocks/boardView'
|
||||
import {MutableBoardTree} from '../viewModel/boardTree'
|
||||
import {WorkspaceComponent} from '../components/workspaceComponent'
|
||||
import {IBlock} from '../blocks/block'
|
||||
import {sendFlashMessage} from '../components/flashMessages'
|
||||
import {WorkspaceComponent} from '../components/workspaceComponent'
|
||||
import mutator from '../mutator'
|
||||
import {OctoListener} from '../octoListener'
|
||||
import {Utils} from '../utils'
|
||||
import {MutableWorkspaceTree} from '../viewModel/workspaceTree'
|
||||
import {BoardTree, MutableBoardTree} from '../viewModel/boardTree'
|
||||
import {MutableWorkspaceTree, WorkspaceTree} from '../viewModel/workspaceTree'
|
||||
|
||||
type Props = {
|
||||
setLanguage: (lang: string) => void
|
||||
@ -18,23 +18,18 @@ type Props = {
|
||||
type State = {
|
||||
boardId: string
|
||||
viewId: string
|
||||
workspaceTree: MutableWorkspaceTree
|
||||
boardTree?: MutableBoardTree
|
||||
workspaceTree: WorkspaceTree
|
||||
boardTree?: BoardTree
|
||||
}
|
||||
|
||||
export default class BoardPage extends React.Component<Props, State> {
|
||||
view: BoardView
|
||||
|
||||
updateTitleTimeout: number
|
||||
updatePropertyLabelTimeout: number
|
||||
|
||||
private workspaceListener = new OctoListener()
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
const queryString = new URLSearchParams(window.location.search)
|
||||
const boardId = queryString.get('id')
|
||||
const viewId = queryString.get('v')
|
||||
const boardId = queryString.get('id') || ''
|
||||
const viewId = queryString.get('v') || ''
|
||||
|
||||
this.state = {
|
||||
boardId,
|
||||
@ -65,7 +60,7 @@ export default class BoardPage extends React.Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
undoRedoHandler = async (e: KeyboardEvent) => {
|
||||
private undoRedoHandler = async (e: KeyboardEvent) => {
|
||||
if (e.target !== document.body) {
|
||||
return
|
||||
}
|
||||
@ -144,41 +139,63 @@ export default class BoardPage extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
private async sync(boardId: string = this.state.boardId, viewId: string | undefined = this.state.viewId) {
|
||||
const {workspaceTree} = this.state
|
||||
Utils.log(`sync start: ${boardId}`)
|
||||
|
||||
const workspaceTree = new MutableWorkspaceTree()
|
||||
await workspaceTree.sync()
|
||||
const boardIds = workspaceTree.boards.map((o) => o.id)
|
||||
this.setState({workspaceTree})
|
||||
|
||||
// Listen to boards plus all blocks at root (Empty string for parentId)
|
||||
this.workspaceListener.open(['', ...boardIds], async (blockId) => {
|
||||
Utils.log(`workspaceListener.onChanged: ${blockId}`)
|
||||
this.sync()
|
||||
})
|
||||
this.workspaceListener.open(
|
||||
['', ...boardIds],
|
||||
async (blocks) => {
|
||||
Utils.log(`workspaceListener.onChanged: ${blocks.length}`)
|
||||
this.incrementalUpdate(blocks)
|
||||
},
|
||||
() => {
|
||||
Utils.log('workspaceListener.onReconnect')
|
||||
this.sync()
|
||||
},
|
||||
)
|
||||
|
||||
if (boardId) {
|
||||
const boardTree = new MutableBoardTree(boardId)
|
||||
await boardTree.sync()
|
||||
|
||||
// Default to first view
|
||||
if (!viewId) {
|
||||
viewId = boardTree.views[0].id
|
||||
}
|
||||
|
||||
boardTree.setActiveView(viewId)
|
||||
boardTree.setActiveView(viewId || boardTree.views[0].id)
|
||||
|
||||
// TODO: Handle error (viewId not found)
|
||||
|
||||
this.setState({
|
||||
boardTree,
|
||||
boardId,
|
||||
viewId: boardTree.activeView.id,
|
||||
viewId: boardTree.activeView!.id,
|
||||
})
|
||||
Utils.log(`sync complete: ${boardTree.board.id} (${boardTree.board.title})`)
|
||||
} else {
|
||||
this.forceUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
private incrementalUpdate(blocks: IBlock[]) {
|
||||
const {workspaceTree, boardTree} = this.state
|
||||
|
||||
let newState = {workspaceTree, boardTree}
|
||||
|
||||
const newWorkspaceTree = workspaceTree.mutableCopy()
|
||||
if (newWorkspaceTree.incrementalUpdate(blocks)) {
|
||||
newState = {...newState, workspaceTree: newWorkspaceTree}
|
||||
}
|
||||
|
||||
const newBoardTree = boardTree ? boardTree.mutableCopy() : new MutableBoardTree(this.state.boardId)
|
||||
if (newBoardTree.incrementalUpdate(blocks)) {
|
||||
newBoardTree.setActiveView(this.state.viewId)
|
||||
newState = {...newState, boardTree: newBoardTree}
|
||||
}
|
||||
|
||||
this.setState(newState)
|
||||
}
|
||||
|
||||
// IPageController
|
||||
showBoard(boardId: string): void {
|
||||
const {boardTree} = this.state
|
||||
@ -194,9 +211,10 @@ export default class BoardPage extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
showView(viewId: string, boardId: string = this.state.boardId): void {
|
||||
if (this.state.boardId === boardId) {
|
||||
this.state.boardTree.setActiveView(viewId)
|
||||
this.setState({...this.state, viewId})
|
||||
if (this.state.boardTree && this.state.boardId === boardId) {
|
||||
const newBoardTree = this.state.boardTree.mutableCopy()
|
||||
newBoardTree.setActiveView(viewId)
|
||||
this.setState({boardTree: newBoardTree, viewId})
|
||||
} else {
|
||||
this.attachToBoard(boardId, viewId)
|
||||
}
|
||||
@ -206,7 +224,13 @@ export default class BoardPage extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
setSearchText(text?: string): void {
|
||||
this.state.boardTree?.setSearchText(text)
|
||||
this.setState({...this.state, boardTree: this.state.boardTree})
|
||||
if (!this.state.boardTree) {
|
||||
Utils.assertFailure('setSearchText: boardTree')
|
||||
return
|
||||
}
|
||||
|
||||
const newBoardTree = this.state.boardTree.mutableCopy()
|
||||
newBoardTree.setSearchText(text)
|
||||
this.setState({boardTree: newBoardTree})
|
||||
}
|
||||
}
|
||||
|
@ -3,25 +3,24 @@
|
||||
import React from 'react'
|
||||
|
||||
import {Utils} from '../utils'
|
||||
|
||||
import Button from '../widgets/buttons/button'
|
||||
|
||||
import './loginPage.scss'
|
||||
|
||||
type Props = {}
|
||||
|
||||
type State = {
|
||||
username: string;
|
||||
password: string;
|
||||
type Props = {
|
||||
}
|
||||
|
||||
export default class LoginPage extends React.Component<Props, State> {
|
||||
type State = {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default class LoginPage extends React.PureComponent<Props, State> {
|
||||
state = {
|
||||
username: '',
|
||||
password: '',
|
||||
}
|
||||
|
||||
handleLogin = () => {
|
||||
private handleLogin = (): void => {
|
||||
Utils.log('Logging in')
|
||||
}
|
||||
|
||||
@ -29,7 +28,7 @@ export default class LoginPage extends React.Component<Props, State> {
|
||||
return (
|
||||
<div className='LoginPage'>
|
||||
<div className='username'>
|
||||
<label htmlFor='login-username'>Username</label>
|
||||
<label htmlFor='login-username'>{'Username'}</label>
|
||||
<input
|
||||
id='login-username'
|
||||
value={this.state.username}
|
||||
@ -37,7 +36,7 @@ export default class LoginPage extends React.Component<Props, State> {
|
||||
/>
|
||||
</div>
|
||||
<div className='password'>
|
||||
<label htmlFor='login-username'>Password</label>
|
||||
<label htmlFor='login-username'>{'Password'}</label>
|
||||
<input
|
||||
id='login-password'
|
||||
type='password'
|
||||
@ -45,7 +44,7 @@ export default class LoginPage extends React.Component<Props, State> {
|
||||
onChange={(e) => this.setState({password: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={this.handleLogin}>Login</Button>
|
||||
<Button onClick={this.handleLogin}>{'Login'}</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
124
webapp/src/undoManager.test.ts
Normal file
124
webapp/src/undoManager.test.ts
Normal 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)
|
||||
})
|
@ -128,11 +128,17 @@ class UndoManager {
|
||||
}
|
||||
|
||||
const currentGroupId = command.groupId
|
||||
do {
|
||||
if (currentGroupId) {
|
||||
do {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.execute(command, 'undo')
|
||||
this.index -= 1
|
||||
command = this.commands[this.index]
|
||||
} while (this.index >= 0 && currentGroupId === command.groupId)
|
||||
} else {
|
||||
await this.execute(command, 'undo')
|
||||
this.index -= 1
|
||||
command = this.commands[this.index]
|
||||
} while (this.index >= 0 && currentGroupId && currentGroupId === command.groupId)
|
||||
}
|
||||
|
||||
if (this.onStateDidChange) {
|
||||
this.onStateDidChange()
|
||||
@ -151,11 +157,17 @@ class UndoManager {
|
||||
}
|
||||
|
||||
const currentGroupId = command.groupId
|
||||
do {
|
||||
if (currentGroupId) {
|
||||
do {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.execute(command, 'redo')
|
||||
this.index += 1
|
||||
command = this.commands[this.index + 1]
|
||||
} while (this.index < this.commands.length - 1 && currentGroupId === command.groupId)
|
||||
} else {
|
||||
await this.execute(command, 'redo')
|
||||
this.index += 1
|
||||
command = this.commands[this.index + 1]
|
||||
} while (this.index < this.commands.length && currentGroupId && currentGroupId === command.groupId)
|
||||
}
|
||||
|
||||
if (this.onStateDidChange) {
|
||||
this.onStateDidChange()
|
||||
|
@ -1,12 +1,12 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {IBlock, IMutableBlock} from '../blocks/block'
|
||||
import {Board, IPropertyOption, IPropertyTemplate, MutableBoard} from '../blocks/board'
|
||||
import {BoardView, MutableBoardView} from '../blocks/boardView'
|
||||
import {Card, MutableCard} from '../blocks/card'
|
||||
import {CardFilter} from '../cardFilter'
|
||||
import {Constants} from '../constants'
|
||||
import octoClient from '../octoClient'
|
||||
import {IBlock, IMutableBlock} from '../blocks/block'
|
||||
import {OctoUtils} from '../octoUtils'
|
||||
import {Utils} from '../utils'
|
||||
|
||||
@ -19,46 +19,65 @@ interface BoardTree {
|
||||
readonly board: Board
|
||||
readonly views: readonly BoardView[]
|
||||
readonly cards: readonly Card[]
|
||||
readonly cardTemplates: readonly Card[]
|
||||
readonly allCards: readonly Card[]
|
||||
readonly visibleGroups: readonly Group[]
|
||||
readonly hiddenGroups: readonly Group[]
|
||||
readonly allBlocks: readonly IBlock[]
|
||||
|
||||
readonly activeView?: BoardView
|
||||
readonly activeView: BoardView
|
||||
readonly groupByProperty?: IPropertyTemplate
|
||||
|
||||
getSearchText(): string | undefined
|
||||
orderedCards(): Card[]
|
||||
|
||||
mutableCopy(): MutableBoardTree
|
||||
}
|
||||
|
||||
class MutableBoardTree implements BoardTree {
|
||||
board!: MutableBoard
|
||||
views: MutableBoardView[] = []
|
||||
cards: MutableCard[] = []
|
||||
cardTemplates: MutableCard[] = []
|
||||
visibleGroups: Group[] = []
|
||||
hiddenGroups: Group[] = []
|
||||
|
||||
activeView?: MutableBoardView
|
||||
activeView!: MutableBoardView
|
||||
groupByProperty?: IPropertyTemplate
|
||||
|
||||
private rawBlocks: IBlock[] = []
|
||||
private searchText?: string
|
||||
allCards: MutableCard[] = []
|
||||
get allBlocks(): IBlock[] {
|
||||
return [this.board, ...this.views, ...this.allCards]
|
||||
return [this.board, ...this.views, ...this.allCards, ...this.cardTemplates]
|
||||
}
|
||||
|
||||
constructor(private boardId: string) {
|
||||
}
|
||||
|
||||
async sync() {
|
||||
const blocks = await octoClient.getSubtree(this.boardId)
|
||||
this.rebuild(OctoUtils.hydrateBlocks(blocks))
|
||||
async sync(): Promise<void> {
|
||||
this.rawBlocks = await octoClient.getSubtree(this.boardId)
|
||||
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
|
||||
}
|
||||
|
||||
incrementalUpdate(updatedBlocks: IBlock[]): boolean {
|
||||
const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.id === this.boardId || block.parentId === this.boardId)
|
||||
if (relevantBlocks.length < 1) {
|
||||
return false
|
||||
}
|
||||
this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, relevantBlocks)
|
||||
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private rebuild(blocks: IMutableBlock[]) {
|
||||
this.board = blocks.find((block) => block.type === 'board') as MutableBoard
|
||||
this.views = blocks.filter((block) => block.type === 'view') as MutableBoardView[]
|
||||
this.allCards = blocks.filter((block) => block.type === 'card') as MutableCard[]
|
||||
this.views = blocks.filter((block) => block.type === 'view').
|
||||
sort((a, b) => a.title.localeCompare(b.title)) as MutableBoardView[]
|
||||
this.allCards = blocks.filter((block) => block.type === 'card' && !(block as Card).isTemplate) as MutableCard[]
|
||||
this.cardTemplates = blocks.filter((block) => block.type === 'card' && (block as Card).isTemplate).
|
||||
sort((a, b) => a.title.localeCompare(b.title)) as MutableCard[]
|
||||
this.cards = []
|
||||
|
||||
this.ensureMinimumSchema()
|
||||
@ -93,16 +112,22 @@ class MutableBoardTree implements BoardTree {
|
||||
didChange = true
|
||||
}
|
||||
|
||||
if (!this.activeView) {
|
||||
this.activeView = this.views[0]
|
||||
}
|
||||
|
||||
return didChange
|
||||
}
|
||||
|
||||
setActiveView(viewId: string) {
|
||||
this.activeView = this.views.find((o) => o.id === viewId)
|
||||
if (!this.activeView) {
|
||||
setActiveView(viewId: string): void {
|
||||
let view = this.views.find((o) => o.id === viewId)
|
||||
if (!view) {
|
||||
Utils.logError(`Cannot find BoardView: ${viewId}`)
|
||||
this.activeView = this.views[0]
|
||||
view = this.views[0]
|
||||
}
|
||||
|
||||
this.activeView = view
|
||||
|
||||
// Fix missing group by (e.g. for new views)
|
||||
if (this.activeView.viewType === 'board' && !this.activeView.groupById) {
|
||||
this.activeView.groupById = this.board.cardProperties.find((o) => o.type === 'select')?.id
|
||||
@ -115,12 +140,12 @@ class MutableBoardTree implements BoardTree {
|
||||
return this.searchText
|
||||
}
|
||||
|
||||
setSearchText(text?: string) {
|
||||
setSearchText(text?: string): void {
|
||||
this.searchText = text
|
||||
this.applyFilterSortAndGroup()
|
||||
}
|
||||
|
||||
applyFilterSortAndGroup() {
|
||||
private applyFilterSortAndGroup(): void {
|
||||
Utils.assert(this.allCards !== undefined)
|
||||
|
||||
this.cards = this.filterCards(this.allCards) as MutableCard[]
|
||||
@ -145,11 +170,7 @@ class MutableBoardTree implements BoardTree {
|
||||
return cards.slice()
|
||||
}
|
||||
|
||||
return cards.filter((card) => {
|
||||
if (card.title?.toLocaleLowerCase().indexOf(searchText) !== -1) {
|
||||
return true
|
||||
}
|
||||
})
|
||||
return cards.filter((card) => card.title?.toLocaleLowerCase().indexOf(searchText) !== -1)
|
||||
}
|
||||
|
||||
private setGroupByProperty(propertyId: string) {
|
||||
@ -170,6 +191,10 @@ class MutableBoardTree implements BoardTree {
|
||||
|
||||
private groupCards() {
|
||||
const {activeView, groupByProperty} = this
|
||||
if (!activeView || !groupByProperty) {
|
||||
Utils.assertFailure('groupCards')
|
||||
return
|
||||
}
|
||||
|
||||
const unassignedOptionIds = groupByProperty.options.
|
||||
filter((o) => !activeView.visibleOptionIds.includes(o.id) && !activeView.hiddenOptionIds.includes(o.id)).
|
||||
@ -203,9 +228,9 @@ class MutableBoardTree implements BoardTree {
|
||||
}
|
||||
} else {
|
||||
// Empty group
|
||||
const emptyGroupCards = this.cards.filter((o) => {
|
||||
const optionId = o.properties[groupByProperty.id]
|
||||
return !optionId || !this.groupByProperty.options.find((option) => option.id === optionId)
|
||||
const emptyGroupCards = this.cards.filter((card) => {
|
||||
const groupByOptionId = card.properties[groupByProperty.id]
|
||||
return !groupByOptionId || !groupByProperty.options.find((option) => option.id === groupByOptionId)
|
||||
})
|
||||
const group: Group = {
|
||||
option: {id: '', value: `No ${groupByProperty.name}`, color: ''},
|
||||
@ -220,7 +245,7 @@ class MutableBoardTree implements BoardTree {
|
||||
|
||||
private filterCards(cards: MutableCard[]): Card[] {
|
||||
const {board} = this
|
||||
const filterGroup = this.activeView?.filter
|
||||
const filterGroup = this.activeView.filter
|
||||
if (!filterGroup) {
|
||||
return cards.slice()
|
||||
}
|
||||
@ -228,27 +253,32 @@ class MutableBoardTree implements BoardTree {
|
||||
return CardFilter.applyFilterGroup(filterGroup, board.cardProperties, cards)
|
||||
}
|
||||
|
||||
private defaultOrder(cardA: Card, cardB: Card) {
|
||||
const {activeView} = this
|
||||
private titleOrCreatedOrder(cardA: Card, cardB: Card) {
|
||||
const aValue = cardA.title
|
||||
const bValue = cardB.title
|
||||
|
||||
if (aValue && bValue) {
|
||||
return aValue.localeCompare(bValue)
|
||||
}
|
||||
|
||||
// Always put untitled cards at the bottom
|
||||
if (aValue && !bValue) {
|
||||
return -1
|
||||
}
|
||||
if (bValue && !aValue) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// If both cards are untitled, use the create date
|
||||
return cardA.createAt - cardB.createAt
|
||||
}
|
||||
|
||||
private manualOrder(activeView: BoardView, cardA: Card, cardB: Card) {
|
||||
const indexA = activeView.cardOrder.indexOf(cardA.id)
|
||||
const indexB = activeView.cardOrder.indexOf(cardB.id)
|
||||
|
||||
if (indexA < 0 && indexB < 0) {
|
||||
// If both cards' order is not defined, first use the title
|
||||
const aValue = cardA.title || ''
|
||||
const bValue = cardB.title || ''
|
||||
|
||||
// Always put untitled cards at the bottom
|
||||
if (aValue && !bValue) {
|
||||
return -1
|
||||
}
|
||||
if (bValue && !aValue) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// If both cards are untitled, use the create date
|
||||
return cardA.createAt - cardB.createAt
|
||||
return this.titleOrCreatedOrder(cardA, cardB)
|
||||
} else if (indexA < 0 && indexB >= 0) {
|
||||
// If cardA's order is not defined, put it at the end
|
||||
return 1
|
||||
@ -257,26 +287,45 @@ class MutableBoardTree implements BoardTree {
|
||||
}
|
||||
|
||||
private sortCards(cards: Card[]): Card[] {
|
||||
if (!this.activeView) {
|
||||
const {board, activeView} = this
|
||||
if (!activeView) {
|
||||
Utils.assertFailure()
|
||||
return cards
|
||||
}
|
||||
const {board} = this
|
||||
const {sortOptions} = this.activeView
|
||||
let sortedCards: Card[] = []
|
||||
const {sortOptions} = activeView
|
||||
|
||||
if (sortOptions.length < 1) {
|
||||
Utils.log('Default sort')
|
||||
sortedCards = cards.sort((a, b) => this.defaultOrder(a, b))
|
||||
} else {
|
||||
sortOptions.forEach((sortOption) => {
|
||||
if (sortOption.propertyId === Constants.titleColumnId) {
|
||||
Utils.log('Sort by name')
|
||||
sortedCards = cards.sort((a, b) => {
|
||||
const aValue = a.title || ''
|
||||
const bValue = b.title || ''
|
||||
Utils.log('Manual sort')
|
||||
return cards.sort((a, b) => this.manualOrder(activeView, a, b))
|
||||
}
|
||||
|
||||
// Always put empty values at the bottom, newest last
|
||||
let sortedCards = cards
|
||||
for (const sortOption of sortOptions) {
|
||||
if (sortOption.propertyId === Constants.titleColumnId) {
|
||||
Utils.log('Sort by title')
|
||||
sortedCards = sortedCards.sort((a, b) => {
|
||||
const result = this.titleOrCreatedOrder(a, b)
|
||||
return sortOption.reversed ? -result : result
|
||||
})
|
||||
} else {
|
||||
const sortPropertyId = sortOption.propertyId
|
||||
const template = board.cardProperties.find((o) => o.id === sortPropertyId)
|
||||
if (!template) {
|
||||
Utils.logError(`Missing template for property id: ${sortPropertyId}`)
|
||||
return sortedCards
|
||||
}
|
||||
Utils.log(`Sort by property: ${template?.name}`)
|
||||
sortedCards = sortedCards.sort((a, b) => {
|
||||
// Always put cards with no titles at the bottom, regardless of sort
|
||||
if (!a.title || !b.title) {
|
||||
return this.titleOrCreatedOrder(a, b)
|
||||
}
|
||||
|
||||
const aValue = a.properties[sortPropertyId] || ''
|
||||
const bValue = b.properties[sortPropertyId] || ''
|
||||
let result = 0
|
||||
if (template.type === 'select') {
|
||||
// Always put empty values at the bottom
|
||||
if (aValue && !bValue) {
|
||||
return -1
|
||||
}
|
||||
@ -284,96 +333,56 @@ class MutableBoardTree implements BoardTree {
|
||||
return 1
|
||||
}
|
||||
if (!aValue && !bValue) {
|
||||
return this.defaultOrder(a, b)
|
||||
return this.titleOrCreatedOrder(a, b)
|
||||
}
|
||||
|
||||
let result = aValue.localeCompare(bValue)
|
||||
if (sortOption.reversed) {
|
||||
result = -result
|
||||
}
|
||||
return result
|
||||
})
|
||||
} else {
|
||||
const sortPropertyId = sortOption.propertyId
|
||||
const template = board.cardProperties.find((o) => o.id === sortPropertyId)
|
||||
if (!template) {
|
||||
Utils.logError(`Missing template for property id: ${sortPropertyId}`)
|
||||
return cards.slice()
|
||||
}
|
||||
Utils.log(`Sort by ${template?.name}`)
|
||||
sortedCards = cards.sort((a, b) => {
|
||||
// Always put cards with no titles at the bottom
|
||||
if (a.title && !b.title) {
|
||||
// Sort by the option order (not alphabetically by value)
|
||||
const aOrder = template.options.findIndex((o) => o.id === aValue)
|
||||
const bOrder = template.options.findIndex((o) => o.id === bValue)
|
||||
|
||||
result = aOrder - bOrder
|
||||
} else if (template.type === 'number' || template.type === 'date') {
|
||||
// Always put empty values at the bottom
|
||||
if (aValue && !bValue) {
|
||||
return -1
|
||||
}
|
||||
if (b.title && !a.title) {
|
||||
if (bValue && !aValue) {
|
||||
return 1
|
||||
}
|
||||
if (!a.title && !b.title) {
|
||||
return this.defaultOrder(a, b)
|
||||
if (!aValue && !bValue) {
|
||||
return this.titleOrCreatedOrder(a, b)
|
||||
}
|
||||
|
||||
const aValue = a.properties[sortPropertyId] || ''
|
||||
const bValue = b.properties[sortPropertyId] || ''
|
||||
let result = 0
|
||||
if (template.type === 'select') {
|
||||
// Always put empty values at the bottom
|
||||
if (aValue && !bValue) {
|
||||
return -1
|
||||
}
|
||||
if (bValue && !aValue) {
|
||||
return 1
|
||||
}
|
||||
if (!aValue && !bValue) {
|
||||
return this.defaultOrder(a, b)
|
||||
}
|
||||
result = Number(aValue) - Number(bValue)
|
||||
} else if (template.type === 'createdTime') {
|
||||
result = a.createAt - b.createAt
|
||||
} else if (template.type === 'updatedTime') {
|
||||
result = a.updateAt - b.updateAt
|
||||
} else {
|
||||
// Text-based sort
|
||||
|
||||
// Sort by the option order (not alphabetically by value)
|
||||
const aOrder = template.options.findIndex((o) => o.id === aValue)
|
||||
const bOrder = template.options.findIndex((o) => o.id === bValue)
|
||||
|
||||
result = aOrder - bOrder
|
||||
} else if (template.type === 'number' || template.type === 'date') {
|
||||
// Always put empty values at the bottom
|
||||
if (aValue && !bValue) {
|
||||
return -1
|
||||
}
|
||||
if (bValue && !aValue) {
|
||||
return 1
|
||||
}
|
||||
if (!aValue && !bValue) {
|
||||
return this.defaultOrder(a, b)
|
||||
}
|
||||
|
||||
result = Number(aValue) - Number(bValue)
|
||||
} else if (template.type === 'createdTime') {
|
||||
result = this.defaultOrder(a, b)
|
||||
} else if (template.type === 'updatedTime') {
|
||||
result = a.updateAt - b.updateAt
|
||||
} else {
|
||||
// Text-based sort
|
||||
|
||||
// Always put empty values at the bottom
|
||||
if (aValue && !bValue) {
|
||||
return -1
|
||||
}
|
||||
if (bValue && !aValue) {
|
||||
return 1
|
||||
}
|
||||
if (!aValue && !bValue) {
|
||||
return this.defaultOrder(a, b)
|
||||
}
|
||||
|
||||
result = aValue.localeCompare(bValue)
|
||||
// Always put empty values at the bottom
|
||||
if (aValue && !bValue) {
|
||||
return -1
|
||||
}
|
||||
if (bValue && !aValue) {
|
||||
return 1
|
||||
}
|
||||
if (!aValue && !bValue) {
|
||||
return this.titleOrCreatedOrder(a, b)
|
||||
}
|
||||
|
||||
if (sortOption.reversed) {
|
||||
result = -result
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
})
|
||||
result = aValue.localeCompare(bValue)
|
||||
}
|
||||
|
||||
if (result === 0) {
|
||||
// In case of "ties", use the title order
|
||||
result = this.titleOrCreatedOrder(a, b)
|
||||
}
|
||||
|
||||
return sortOption.reversed ? -result : result
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sortedCards
|
||||
@ -390,6 +399,12 @@ class MutableBoardTree implements BoardTree {
|
||||
|
||||
return cards
|
||||
}
|
||||
|
||||
mutableCopy(): MutableBoardTree {
|
||||
const boardTree = new MutableBoardTree(this.boardId)
|
||||
boardTree.incrementalUpdate(this.rawBlocks)
|
||||
return boardTree
|
||||
}
|
||||
}
|
||||
|
||||
export {MutableBoardTree, BoardTree, Group as BoardTreeGroup}
|
||||
|
@ -1,32 +1,47 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {Card} from '../blocks/card'
|
||||
import {IBlock, MutableBlock} from '../blocks/block'
|
||||
import {Card, MutableCard} from '../blocks/card'
|
||||
import {IOrderedBlock} from '../blocks/orderedBlock'
|
||||
import octoClient from '../octoClient'
|
||||
import {IBlock} from '../blocks/block'
|
||||
import {OctoUtils} from '../octoUtils'
|
||||
|
||||
interface CardTree {
|
||||
readonly card: Card
|
||||
readonly comments: readonly IBlock[]
|
||||
readonly contents: readonly IOrderedBlock[]
|
||||
|
||||
mutableCopy(): MutableCardTree
|
||||
templateCopy(): MutableCardTree
|
||||
}
|
||||
|
||||
class MutableCardTree implements CardTree {
|
||||
card: Card
|
||||
comments: IBlock[]
|
||||
contents: IOrderedBlock[]
|
||||
card!: MutableCard
|
||||
comments: IBlock[] = []
|
||||
contents: IOrderedBlock[] = []
|
||||
|
||||
private rawBlocks: IBlock[] = []
|
||||
|
||||
constructor(private cardId: string) {
|
||||
}
|
||||
|
||||
async sync() {
|
||||
const blocks = await octoClient.getSubtree(this.cardId)
|
||||
this.rebuild(OctoUtils.hydrateBlocks(blocks))
|
||||
async sync(): Promise<void> {
|
||||
this.rawBlocks = await octoClient.getSubtree(this.cardId)
|
||||
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
|
||||
}
|
||||
|
||||
incrementalUpdate(updatedBlocks: IBlock[]): boolean {
|
||||
const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.id === this.cardId || block.parentId === this.cardId)
|
||||
if (relevantBlocks.length < 1) {
|
||||
return false
|
||||
}
|
||||
this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, relevantBlocks)
|
||||
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
|
||||
return true
|
||||
}
|
||||
|
||||
private rebuild(blocks: IBlock[]) {
|
||||
this.card = blocks.find((o) => o.id === this.cardId) as Card
|
||||
this.card = blocks.find((o) => o.id === this.cardId) as MutableCard
|
||||
|
||||
this.comments = blocks.
|
||||
filter((block) => block.type === 'comment').
|
||||
@ -35,6 +50,26 @@ class MutableCardTree implements CardTree {
|
||||
const contentBlocks = blocks.filter((block) => block.type === 'text' || block.type === 'image' || block.type === 'divider') as IOrderedBlock[]
|
||||
this.contents = contentBlocks.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
|
||||
mutableCopy(): MutableCardTree {
|
||||
const cardTree = new MutableCardTree(this.cardId)
|
||||
cardTree.incrementalUpdate(this.rawBlocks)
|
||||
return cardTree
|
||||
}
|
||||
|
||||
templateCopy(): MutableCardTree {
|
||||
const card = this.card.duplicate()
|
||||
|
||||
const contents: IOrderedBlock[] = this.contents.map((content) => {
|
||||
const copy = MutableBlock.duplicate(content)
|
||||
copy.parentId = card.id
|
||||
return copy as IOrderedBlock
|
||||
})
|
||||
|
||||
const cardTree = new MutableCardTree(card.id)
|
||||
cardTree.incrementalUpdate([card, ...contents])
|
||||
return cardTree
|
||||
}
|
||||
}
|
||||
|
||||
export {MutableCardTree, CardTree}
|
||||
|
@ -1,35 +1,53 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {Board} from '../blocks/board'
|
||||
import octoClient from '../octoClient'
|
||||
import {IBlock} from '../blocks/block'
|
||||
import {OctoUtils} from '../octoUtils'
|
||||
import {Board} from '../blocks/board'
|
||||
import {BoardView} from '../blocks/boardView'
|
||||
import octoClient from '../octoClient'
|
||||
import {OctoUtils} from '../octoUtils'
|
||||
|
||||
interface WorkspaceTree {
|
||||
readonly boards: readonly Board[]
|
||||
readonly views: readonly BoardView[]
|
||||
|
||||
mutableCopy(): MutableWorkspaceTree
|
||||
}
|
||||
|
||||
class MutableWorkspaceTree {
|
||||
boards: Board[] = []
|
||||
views: BoardView[] = []
|
||||
|
||||
async sync() {
|
||||
const boards = await octoClient.getBlocksWithType('board')
|
||||
const views = await octoClient.getBlocksWithType('view')
|
||||
this.rebuild(
|
||||
OctoUtils.hydrateBlocks(boards),
|
||||
OctoUtils.hydrateBlocks(views),
|
||||
)
|
||||
private rawBlocks: IBlock[] = []
|
||||
|
||||
async sync(): Promise<void> {
|
||||
const rawBoards = await octoClient.getBlocksWithType('board')
|
||||
const rawViews = await octoClient.getBlocksWithType('view')
|
||||
this.rawBlocks = [...rawBoards, ...rawViews]
|
||||
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
|
||||
}
|
||||
|
||||
private rebuild(boards: IBlock[], views: IBlock[]) {
|
||||
this.boards = boards.filter((block) => block.type === 'board') as Board[]
|
||||
this.views = views.filter((block) => block.type === 'view') as BoardView[]
|
||||
incrementalUpdate(updatedBlocks: IBlock[]): boolean {
|
||||
const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.type === 'board' || block.type === 'view')
|
||||
if (relevantBlocks.length < 1) {
|
||||
return false
|
||||
}
|
||||
this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, updatedBlocks)
|
||||
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
|
||||
return true
|
||||
}
|
||||
|
||||
private rebuild(blocks: IBlock[]) {
|
||||
this.boards = blocks.filter((block) => block.type === 'board').
|
||||
sort((a, b) => a.title.localeCompare(b.title)) as Board[]
|
||||
this.views = blocks.filter((block) => block.type === 'view').
|
||||
sort((a, b) => a.title.localeCompare(b.title)) as BoardView[]
|
||||
}
|
||||
|
||||
mutableCopy(): MutableWorkspaceTree {
|
||||
const workspaceTree = new MutableWorkspaceTree()
|
||||
workspaceTree.incrementalUpdate(this.rawBlocks)
|
||||
return workspaceTree
|
||||
}
|
||||
}
|
||||
|
||||
// type WorkspaceTree = Readonly<MutableWorkspaceTree>
|
||||
|
||||
export {MutableWorkspaceTree, WorkspaceTree}
|
||||
|
@ -10,8 +10,8 @@ type Props = {
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export default class Button extends React.Component<Props> {
|
||||
render() {
|
||||
export default class Button extends React.PureComponent<Props> {
|
||||
render(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
onClick={this.props.onClick}
|
||||
|
@ -9,9 +9,10 @@ type Props = {
|
||||
value?: string
|
||||
placeholderText?: string
|
||||
className?: string
|
||||
saveOnEsc?: boolean
|
||||
|
||||
onCancel?: () => void
|
||||
onSave?: (saveType: 'onEnter'|'onBlur') => void
|
||||
onSave?: (saveType: 'onEnter'|'onEsc'|'onBlur') => void
|
||||
}
|
||||
|
||||
export default class Editable extends React.Component<Props> {
|
||||
@ -23,16 +24,16 @@ export default class Editable extends React.Component<Props> {
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
this.elementRef.current.focus()
|
||||
this.elementRef.current!.focus()
|
||||
|
||||
// Put cursor at end
|
||||
document.execCommand('selectAll', false, null)
|
||||
document.getSelection().collapseToEnd()
|
||||
document.execCommand('selectAll', false, undefined)
|
||||
document.getSelection()?.collapseToEnd()
|
||||
}
|
||||
|
||||
public blur = (): void => {
|
||||
this.saveOnBlur = false
|
||||
this.elementRef.current.blur()
|
||||
this.elementRef.current!.blur()
|
||||
this.saveOnBlur = true
|
||||
}
|
||||
|
||||
@ -52,15 +53,15 @@ export default class Editable extends React.Component<Props> {
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC
|
||||
e.stopPropagation()
|
||||
if (this.props.onCancel) {
|
||||
this.props.onCancel()
|
||||
if (this.props.saveOnEsc) {
|
||||
this.props.onSave?.('onEsc')
|
||||
} else {
|
||||
this.props.onCancel?.()
|
||||
}
|
||||
this.blur()
|
||||
} else if (e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Return
|
||||
e.stopPropagation()
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave('onEnter')
|
||||
}
|
||||
this.props.onSave?.('onEnter')
|
||||
this.blur()
|
||||
}
|
||||
}}
|
||||
|
6
webapp/src/widgets/icons/disclosureTriangle.scss
Normal file
6
webapp/src/widgets/icons/disclosureTriangle.scss
Normal file
@ -0,0 +1,6 @@
|
||||
.DisclosureTriangleIcon {
|
||||
fill: rgba(var(--main-fg), 0.7);
|
||||
stroke: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
18
webapp/src/widgets/icons/disclosureTriangle.tsx
Normal file
18
webapp/src/widgets/icons/disclosureTriangle.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -11,7 +11,8 @@ type ColorOptionProps = MenuOptionProps & {
|
||||
}
|
||||
|
||||
export default class ColorOption extends React.PureComponent<ColorOptionProps> {
|
||||
private handleOnClick = (): void => {
|
||||
private handleOnClick = (e: React.MouseEvent): void => {
|
||||
e.target.dispatchEvent(new Event('menuItemClicked'))
|
||||
this.props.onClick(this.props.id)
|
||||
}
|
||||
|
||||
|
@ -19,6 +19,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
flex-grow: 1;
|
||||
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
@ -31,17 +33,23 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
white-space: nowrap;
|
||||
font-weight: 400;
|
||||
padding: 2px 10px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
|
||||
* {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(90, 90, 90, 0.1);
|
||||
}
|
||||
|
||||
.menu-name {
|
||||
flex-grow: 1;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.SubmenuTriangleIcon {
|
||||
|
@ -1,10 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export type MenuOptionProps = {
|
||||
id: string,
|
||||
name: string,
|
||||
onClick?: (id: string) => void,
|
||||
onClick: (id: string) => void,
|
||||
}
|
||||
|
@ -4,11 +4,11 @@ import React from 'react'
|
||||
|
||||
import SubmenuTriangleIcon from '../icons/submenuTriangle'
|
||||
|
||||
import {MenuOptionProps} from './menuItem'
|
||||
|
||||
import './subMenuOption.scss'
|
||||
|
||||
type SubMenuOptionProps = MenuOptionProps & {
|
||||
type SubMenuOptionProps = {
|
||||
id: string,
|
||||
name: string,
|
||||
position?: 'bottom' | 'top'
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
@ -12,7 +12,8 @@ type SwitchOptionProps = MenuOptionProps & {
|
||||
}
|
||||
|
||||
export default class SwitchOption extends React.PureComponent<SwitchOptionProps> {
|
||||
private handleOnClick = (): void => {
|
||||
private handleOnClick = (e: React.MouseEvent): void => {
|
||||
e.target.dispatchEvent(new Event('menuItemClicked'))
|
||||
this.props.onClick(this.props.id)
|
||||
}
|
||||
|
||||
|
@ -10,7 +10,8 @@ type TextOptionProps = MenuOptionProps & {
|
||||
}
|
||||
|
||||
export default class TextOption extends React.PureComponent<TextOptionProps> {
|
||||
private handleOnClick = (): void => {
|
||||
private handleOnClick = (e: React.MouseEvent): void => {
|
||||
e.target.dispatchEvent(new Event('menuItemClicked'))
|
||||
this.props.onClick(this.props.id)
|
||||
}
|
||||
|
||||
|
@ -31,12 +31,14 @@ export default class MenuWrapper extends React.PureComponent<Props, State> {
|
||||
this.node = React.createRef()
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
document.addEventListener('menuItemClicked', this.close, true)
|
||||
document.addEventListener('click', this.closeOnBlur, true)
|
||||
document.addEventListener('keyup', this.keyboardClose, true)
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
public componentWillUnmount(): void {
|
||||
document.removeEventListener('menuItemClicked', this.close, true)
|
||||
document.removeEventListener('click', this.closeOnBlur, true)
|
||||
document.removeEventListener('keyup', this.keyboardClose, true)
|
||||
}
|
||||
@ -59,13 +61,13 @@ export default class MenuWrapper extends React.PureComponent<Props, State> {
|
||||
this.close()
|
||||
}
|
||||
|
||||
public close = () => {
|
||||
public close = (): void => {
|
||||
if (this.state.open) {
|
||||
this.setState({open: false})
|
||||
}
|
||||
}
|
||||
|
||||
toggle = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
private toggle = (e: React.MouseEvent<HTMLDivElement, MouseEvent>): void => {
|
||||
/**
|
||||
* This is only here so that we can toggle the menus in the sidebar, because the default behavior of the mobile
|
||||
* version (ie the one that uses a modal) needs propagation to close the modal after selecting something
|
||||
@ -80,7 +82,7 @@ export default class MenuWrapper extends React.PureComponent<Props, State> {
|
||||
this.setState({open: newState})
|
||||
}
|
||||
|
||||
public render() {
|
||||
public render(): JSX.Element {
|
||||
const {children} = this.props
|
||||
|
||||
return (
|
||||
|
@ -25,18 +25,14 @@ type State = {
|
||||
export default class PropertyMenu extends React.PureComponent<Props, State> {
|
||||
private nameTextbox = React.createRef<HTMLInputElement>()
|
||||
|
||||
public shouldComponentUpdate(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {name: this.props.propertyName}
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.nameTextbox.current.focus()
|
||||
document.execCommand('selectAll', false, null)
|
||||
this.nameTextbox.current?.focus()
|
||||
document.execCommand('selectAll', false, undefined)
|
||||
}
|
||||
|
||||
private typeDisplayName(type: PropertyType): string {
|
||||
|
@ -18,12 +18,12 @@ import './valueSelector.scss'
|
||||
|
||||
type Props = {
|
||||
options: IPropertyOption[]
|
||||
value: IPropertyOption;
|
||||
emptyValue: string;
|
||||
onCreate?: (value: string) => void
|
||||
onChange?: (value: string) => void
|
||||
onChangeColor?: (option: IPropertyOption, color: string) => void
|
||||
onDeleteOption?: (option: IPropertyOption) => void
|
||||
value?: IPropertyOption
|
||||
emptyValue: string
|
||||
onCreate: (value: string) => void
|
||||
onChange: (value: string) => void
|
||||
onChangeColor: (option: IPropertyOption, color: string) => void
|
||||
onDeleteOption: (option: IPropertyOption) => void
|
||||
intl: IntlShape
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
"esModuleInterop": true,
|
||||
"noImplicitAny": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": false,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"sourceMap": true,
|
||||
"allowJs": true,
|
||||
@ -26,5 +26,9 @@
|
||||
"."
|
||||
],
|
||||
"exclude": [
|
||||
]
|
||||
".git",
|
||||
"**/node_modules/*",
|
||||
"dist",
|
||||
"pack"
|
||||
]
|
||||
}
|
||||
|
Reference in New Issue
Block a user