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