mirror of
https://github.com/mattermost/focalboard.git
synced 2025-05-13 21:37:37 +02:00
Merge branch 'main' into small-improvements
This commit is contained in:
commit
ef8c9ceccd
2
.gitignore
vendored
2
.gitignore
vendored
@ -35,4 +35,4 @@ bin
|
|||||||
debug
|
debug
|
||||||
__debug_bin
|
__debug_bin
|
||||||
files
|
files
|
||||||
octo.db
|
octo*.db
|
||||||
|
5
Makefile
5
Makefile
@ -26,6 +26,11 @@ watch:
|
|||||||
|
|
||||||
prebuild:
|
prebuild:
|
||||||
npm install
|
npm install
|
||||||
|
go get github.com/gorilla/mux
|
||||||
|
go get github.com/gorilla/websocket
|
||||||
|
go get github.com/spf13/viper
|
||||||
|
go get github.com/lib/pq
|
||||||
|
go get github.com/mattn/go-sqlite3
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin
|
rm -rf bin
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
## Build instructions
|
## Build instructions
|
||||||
|
|
||||||
```
|
```
|
||||||
npm i
|
make prebuild
|
||||||
make
|
make
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="/main.css">
|
|
||||||
<link rel="stylesheet" href="/images.css">
|
|
||||||
<link rel="stylesheet" href="/colors.css">
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.location.href = "/boards"
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="container">
|
|
||||||
<header id="header">
|
|
||||||
<a href="/">OCTO</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main id="main">
|
|
||||||
<p>
|
|
||||||
<a href="boards">All Boards</a>
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer id="footer">
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<div id="overlay">
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
@ -11,22 +11,12 @@
|
|||||||
<link rel="stylesheet" href="/colors.css">
|
<link rel="stylesheet" href="/colors.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="container">
|
<body>
|
||||||
<header id="header">
|
<div id="octo-tasks-app">
|
||||||
<a href="/">OCTO</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main id="main">
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer id="footer">
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<div id="overlay">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="modal">
|
<div id="root-portal">
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
16095
package-lock.json
generated
16095
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@ -6,22 +6,39 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"pack": "NODE_ENV=production webpack --config webpack.js",
|
"pack": "NODE_ENV=production webpack --config webpack.js",
|
||||||
"packdev": "NODE_ENV=dev webpack --config webpack.dev.js",
|
"packdev": "NODE_ENV=dev webpack --config webpack.dev.js",
|
||||||
"watchdev": "NODE_ENV=dev webpack --watch --config webpack.dev.js"
|
"watchdev": "NODE_ENV=dev webpack --watch --config webpack.dev.js",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"marked": "^1.1.1",
|
"marked": "^1.1.1",
|
||||||
"react": "^16.13.1",
|
"react": "^16.13.1",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^16.13.1",
|
||||||
|
"react-router-dom": "^5.2.0",
|
||||||
"react-simplemde-editor": "^4.1.3"
|
"react-simplemde-editor": "^4.1.3"
|
||||||
},
|
},
|
||||||
|
"jest": {
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.tsx?$": "ts-jest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^5.11.4",
|
||||||
|
"@testing-library/react": "^11.0.4",
|
||||||
|
"@types/jest": "^26.0.14",
|
||||||
"@types/marked": "^1.1.0",
|
"@types/marked": "^1.1.0",
|
||||||
"@types/react": "^16.9.49",
|
"@types/react": "^16.9.49",
|
||||||
"@types/react-dom": "^16.9.8",
|
"@types/react-dom": "^16.9.8",
|
||||||
|
"@types/react-router-dom": "^5.1.6",
|
||||||
"copy-webpack-plugin": "^6.0.3",
|
"copy-webpack-plugin": "^6.0.3",
|
||||||
|
"css-loader": "^4.3.0",
|
||||||
"file-loader": "^6.1.0",
|
"file-loader": "^6.1.0",
|
||||||
"html-webpack-plugin": "^4.5.0",
|
"html-webpack-plugin": "^4.5.0",
|
||||||
|
"jest": "^26.5.3",
|
||||||
|
"sass": "^1.27.0",
|
||||||
|
"sass-loader": "^10.0.2",
|
||||||
|
"style-loader": "^1.3.0",
|
||||||
"terser-webpack-plugin": "^4.1.0",
|
"terser-webpack-plugin": "^4.1.0",
|
||||||
|
"ts-jest": "^26.4.1",
|
||||||
"ts-loader": "^8.0.3",
|
"ts-loader": "^8.0.3",
|
||||||
"typescript": "^4.0.2",
|
"typescript": "^4.0.2",
|
||||||
"webpack": "^4.44.1",
|
"webpack": "^4.44.1",
|
||||||
|
@ -1,65 +1,46 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Configuration is the app configuration stored in a json file
|
// Configuration is the app configuration stored in a json file
|
||||||
type Configuration struct {
|
type Configuration struct {
|
||||||
ServerRoot string `json:"serverRoot"`
|
ServerRoot string `json:"serverRoot" mapstructure:"serverRoot"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port" mapstructure:"port"`
|
||||||
DBType string `json:"dbtype"`
|
DBType string `json:"dbtype" mapstructure:"dbtype"`
|
||||||
DBConfigString string `json:"dbconfig"`
|
DBConfigString string `json:"dbconfig" mapstructure:"dbconfig"`
|
||||||
UseSSL bool `json:"useSSL"`
|
UseSSL bool `json:"useSSL" mapstructure:"useSSL"`
|
||||||
WebPath string `json:"webpath"`
|
WebPath string `json:"webpath" mapstructure:"webpath"`
|
||||||
FilesPath string `json:"filespath"`
|
FilesPath string `json:"filespath" mapstructure:"filespath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func readConfigFile() Configuration {
|
func readConfigFile() (*Configuration, error) {
|
||||||
fileName := "config.json"
|
viper.SetConfigName("config") // name of config file (without extension)
|
||||||
if !fileExists(fileName) {
|
viper.SetConfigType("json") // REQUIRED if the config file does not have the extension in the name
|
||||||
log.Println(`config.json not found, using default settings`)
|
viper.AddConfigPath(".") // optionally look for config in the working directory
|
||||||
return Configuration{}
|
viper.SetDefault("ServerRoot", "http://localhost")
|
||||||
|
viper.SetDefault("Port", 8000)
|
||||||
|
viper.SetDefault("DBType", "sqlite3")
|
||||||
|
viper.SetDefault("DBConfigString", "./octo.db")
|
||||||
|
viper.SetDefault("WebPath", "./pack")
|
||||||
|
viper.SetDefault("FilesPath", "./files")
|
||||||
|
|
||||||
|
err := viper.ReadInConfig() // Find and read the config file
|
||||||
|
if err != nil { // Handle errors reading the config file
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
file, _ := os.Open(fileName)
|
|
||||||
defer file.Close()
|
|
||||||
decoder := json.NewDecoder(file)
|
|
||||||
configuration := Configuration{}
|
configuration := Configuration{}
|
||||||
err := decoder.Decode(&configuration)
|
err = viper.Unmarshal(&configuration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Invalid config.json", err)
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
// Apply defaults
|
|
||||||
if len(configuration.ServerRoot) < 1 {
|
|
||||||
configuration.ServerRoot = "http://localhost"
|
|
||||||
}
|
|
||||||
|
|
||||||
if configuration.Port == 0 {
|
|
||||||
configuration.Port = 8000
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(configuration.DBType) < 1 {
|
|
||||||
configuration.DBType = "sqlite3"
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(configuration.DBConfigString) < 1 {
|
|
||||||
configuration.DBConfigString = "./octo.db"
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(configuration.WebPath) < 1 {
|
|
||||||
configuration.WebPath = "./pack"
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(configuration.FilesPath) < 1 {
|
|
||||||
configuration.FilesPath = "./files"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("readConfigFile")
|
log.Println("readConfigFile")
|
||||||
log.Printf("%+v", configuration)
|
log.Printf("%+v", configuration)
|
||||||
|
|
||||||
return configuration
|
return &configuration, nil
|
||||||
}
|
}
|
||||||
|
@ -17,27 +17,12 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var config Configuration
|
var config Configuration
|
||||||
|
var wsServer *WSServer
|
||||||
var store *SQLStore
|
var store *SQLStore
|
||||||
|
|
||||||
// WebsocketMsg is send on block changes
|
|
||||||
type WebsocketMsg struct {
|
|
||||||
Action string `json:"action"`
|
|
||||||
BlockID string `json:"blockId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// A single session for now
|
|
||||||
var session = new(ListenerSession)
|
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------------------
|
||||||
// HTTP handlers
|
// HTTP handlers
|
||||||
|
|
||||||
@ -55,6 +40,13 @@ func handleStaticFile(r *mux.Router, requestPath string, filePath string, conten
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleDefault(r *mux.Router, requestPath string) {
|
||||||
|
r.HandleFunc(requestPath, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("handleDefault")
|
||||||
|
http.Redirect(w, r, "/board", http.StatusFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------------------
|
||||||
// REST APIs
|
// REST APIs
|
||||||
|
|
||||||
@ -64,12 +56,15 @@ func handleGetBlocks(w http.ResponseWriter, r *http.Request) {
|
|||||||
blockType := query.Get("type")
|
blockType := query.Get("type")
|
||||||
|
|
||||||
var blocks []string
|
var blocks []string
|
||||||
if len(blockType) > 0 {
|
if len(blockType) > 0 && len(parentID) > 0 {
|
||||||
blocks = store.getBlocksWithParentAndType(parentID, blockType)
|
blocks = store.getBlocksWithParentAndType(parentID, blockType)
|
||||||
|
} else if len(blockType) > 0 {
|
||||||
|
blocks = store.getBlocksWithType(blockType)
|
||||||
} else {
|
} else {
|
||||||
blocks = store.getBlocksWithParent(parentID)
|
blocks = store.getBlocksWithParent(parentID)
|
||||||
}
|
}
|
||||||
log.Printf("GetBlocks parentID: %s, %d result(s)", parentID, len(blocks))
|
|
||||||
|
log.Printf("GetBlocks parentID: %s, type: %s, %d result(s)", parentID, blockType, len(blocks))
|
||||||
response := `[` + strings.Join(blocks[:], ",") + `]`
|
response := `[` + strings.Join(blocks[:], ",") + `]`
|
||||||
jsonResponse(w, http.StatusOK, response)
|
jsonResponse(w, http.StatusOK, response)
|
||||||
}
|
}
|
||||||
@ -131,7 +126,7 @@ func handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
|||||||
store.insertBlock(block, string(jsonBytes))
|
store.insertBlock(block, string(jsonBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
|
wsServer.broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
|
||||||
|
|
||||||
log.Printf("POST Blocks %d block(s)", len(blocks))
|
log.Printf("POST Blocks %d block(s)", len(blocks))
|
||||||
jsonResponse(w, http.StatusOK, "{}")
|
jsonResponse(w, http.StatusOK, "{}")
|
||||||
@ -151,7 +146,7 @@ func handleDeleteBlock(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
store.deleteBlock(blockID)
|
store.deleteBlock(blockID)
|
||||||
|
|
||||||
broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
|
wsServer.broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
|
||||||
|
|
||||||
log.Printf("DELETE Block %s", blockID)
|
log.Printf("DELETE Block %s", blockID)
|
||||||
jsonResponse(w, http.StatusOK, "{}")
|
jsonResponse(w, http.StatusOK, "{}")
|
||||||
@ -292,66 +287,6 @@ func errorResponse(w http.ResponseWriter, code int, message string) {
|
|||||||
// ----------------------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------------------
|
||||||
// WebSocket OnChange listener
|
// WebSocket OnChange listener
|
||||||
|
|
||||||
func handleWebSocketOnChange(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Upgrade initial GET request to a websocket
|
|
||||||
ws, err := upgrader.Upgrade(w, r, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Auth
|
|
||||||
|
|
||||||
query := r.URL.Query()
|
|
||||||
blockID := query.Get("id")
|
|
||||||
log.Printf("CONNECT WebSocket onChange, blockID: %s, client: %s", blockID, ws.RemoteAddr())
|
|
||||||
|
|
||||||
// Make sure we close the connection when the function returns
|
|
||||||
defer func() {
|
|
||||||
log.Printf("DISCONNECT WebSocket onChange, blockID: %s, client: %s", blockID, ws.RemoteAddr())
|
|
||||||
|
|
||||||
// Remove client from listeners
|
|
||||||
session.RemoveListener(ws)
|
|
||||||
|
|
||||||
ws.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Register our new client
|
|
||||||
session.AddListener(ws, blockID)
|
|
||||||
|
|
||||||
// TODO: Implement WebSocket message pump
|
|
||||||
// Simple message handling loop
|
|
||||||
for {
|
|
||||||
_, _, err := ws.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("ERROR WebSocket onChange, blockID: %s, client: %s, err: %v", blockID, ws.RemoteAddr(), err)
|
|
||||||
session.RemoveListener(ws)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func broadcastBlockChangeToWebsocketClients(blockIDs []string) {
|
|
||||||
for _, blockID := range blockIDs {
|
|
||||||
listeners := session.GetListeners(blockID)
|
|
||||||
log.Printf("%d listener(s) for blockID: %s", len(listeners), blockID)
|
|
||||||
|
|
||||||
if listeners != nil {
|
|
||||||
var message = WebsocketMsg{
|
|
||||||
Action: "UPDATE_BLOCK",
|
|
||||||
BlockID: blockID,
|
|
||||||
}
|
|
||||||
for _, listener := range listeners {
|
|
||||||
log.Printf("Broadcast change, blockID: %s, remoteAddr: %s", blockID, listener.RemoteAddr())
|
|
||||||
err := listener.WriteJSON(message)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("broadcast error: %v", err)
|
|
||||||
listener.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isProcessRunning(pid int) bool {
|
func isProcessRunning(pid int) bool {
|
||||||
process, err := os.FindProcess(pid)
|
process, err := os.FindProcess(pid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -381,7 +316,12 @@ func monitorPid(pid int) {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// config.json file
|
// config.json file
|
||||||
config = readConfigFile()
|
var err error
|
||||||
|
config, err = readConfigFile()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Unable to read the config file: ", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Command line args
|
// Command line args
|
||||||
pMonitorPid := flag.Int("monitorpid", -1, "a process ID")
|
pMonitorPid := flag.Int("monitorpid", -1, "a process ID")
|
||||||
@ -398,14 +338,16 @@ func main() {
|
|||||||
config.Port = *pPort
|
config.Port = *pPort
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wsServer = NewWSServer()
|
||||||
|
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
handleStaticFile(r, "/", "index.html", "text/html; charset=utf-8")
|
handleDefault(r, "/")
|
||||||
handleStaticFile(r, "/boards", "boards.html", "text/html; charset=utf-8")
|
|
||||||
handleStaticFile(r, "/board", "board.html", "text/html; charset=utf-8")
|
|
||||||
|
|
||||||
handleStaticFile(r, "/boardsPage.js", "boardsPage.js", "text/javascript; charset=utf-8")
|
handleStaticFile(r, "/login", "index.html", "text/html; charset=utf-8")
|
||||||
|
handleStaticFile(r, "/board", "index.html", "text/html; charset=utf-8")
|
||||||
|
handleStaticFile(r, "/main.js", "main.js", "text/javascript; charset=utf-8")
|
||||||
handleStaticFile(r, "/boardPage.js", "boardPage.js", "text/javascript; charset=utf-8")
|
handleStaticFile(r, "/boardPage.js", "boardPage.js", "text/javascript; charset=utf-8")
|
||||||
|
|
||||||
handleStaticFile(r, "/favicon.ico", "static/favicon.svg", "image/svg+xml; charset=utf-8")
|
handleStaticFile(r, "/favicon.ico", "static/favicon.svg", "image/svg+xml; charset=utf-8")
|
||||||
@ -427,7 +369,7 @@ func main() {
|
|||||||
r.HandleFunc("/api/v1/blocks/import", handleImport).Methods("POST")
|
r.HandleFunc("/api/v1/blocks/import", handleImport).Methods("POST")
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
r.HandleFunc("/ws/onchange", handleWebSocketOnChange)
|
r.HandleFunc("/ws/onchange", wsServer.handleWebSocketOnChange)
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
r.HandleFunc("/files/{filename}", handleServeFile).Methods("GET")
|
r.HandleFunc("/files/{filename}", handleServeFile).Methods("GET")
|
||||||
|
@ -149,6 +149,32 @@ func (s *SQLStore) getBlocksWithParent(parentID string) []string {
|
|||||||
return blocksFromRows(rows)
|
return blocksFromRows(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) getBlocksWithType(blockID string) []string {
|
||||||
|
query := `WITH latest AS
|
||||||
|
(
|
||||||
|
SELECT * FROM
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||||
|
FROM blocks
|
||||||
|
) a
|
||||||
|
WHERE rn = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT COALESCE("json", '{}')
|
||||||
|
FROM latest
|
||||||
|
WHERE delete_at = 0 and type = $1`
|
||||||
|
|
||||||
|
rows, err := db.Query(query, blockType)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf(`getBlocksWithParentAndType ERROR: %v`, err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocksFromRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SQLStore) getSubTree(blockID string) []string {
|
func (s *SQLStore) getSubTree(blockID string) []string {
|
||||||
query := `WITH latest AS
|
query := `WITH latest AS
|
||||||
(
|
(
|
||||||
|
128
server/main/websockets.go
Normal file
128
server/main/websockets.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddListener adds a listener for a blockID's change
|
||||||
|
func (ws *WSServer) AddListener(client *websocket.Conn, blockID string) {
|
||||||
|
ws.mu.Lock()
|
||||||
|
if ws.listeners[blockID] == nil {
|
||||||
|
ws.listeners[blockID] = []*websocket.Conn{}
|
||||||
|
}
|
||||||
|
ws.listeners[blockID] = append(ws.listeners[blockID], client)
|
||||||
|
ws.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveListener removes a webSocket listener
|
||||||
|
func (ws *WSServer) RemoveListener(client *websocket.Conn) {
|
||||||
|
ws.mu.Lock()
|
||||||
|
for key, clients := range ws.listeners {
|
||||||
|
var listeners = []*websocket.Conn{}
|
||||||
|
for _, existingClient := range clients {
|
||||||
|
if client != existingClient {
|
||||||
|
listeners = append(listeners, existingClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.listeners[key] = listeners
|
||||||
|
}
|
||||||
|
ws.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetListeners returns the listeners to a blockID's changes
|
||||||
|
func (ws *WSServer) GetListeners(blockID string) []*websocket.Conn {
|
||||||
|
ws.mu.Lock()
|
||||||
|
listeners := ws.listeners[blockID]
|
||||||
|
ws.mu.Unlock()
|
||||||
|
|
||||||
|
return listeners
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSServer is a WebSocket server
|
||||||
|
type WSServer struct {
|
||||||
|
upgrader websocket.Upgrader
|
||||||
|
listeners map[string][]*websocket.Conn
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWSServer creates a new WSServer
|
||||||
|
func NewWSServer() *WSServer {
|
||||||
|
return &WSServer{
|
||||||
|
listeners: make(map[string][]*websocket.Conn),
|
||||||
|
upgrader: websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebsocketMsg is sent on block changes
|
||||||
|
type WebsocketMsg struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
BlockID string `json:"blockId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WSServer) handleWebSocketOnChange(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Upgrade initial GET request to a websocket
|
||||||
|
client, err := ws.upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Auth
|
||||||
|
|
||||||
|
query := r.URL.Query()
|
||||||
|
blockID := query.Get("id")
|
||||||
|
log.Printf("CONNECT WebSocket onChange, blockID: %s, client: %s", blockID, client.RemoteAddr())
|
||||||
|
|
||||||
|
// Make sure we close the connection when the function returns
|
||||||
|
defer func() {
|
||||||
|
log.Printf("DISCONNECT WebSocket onChange, blockID: %s, client: %s", blockID, client.RemoteAddr())
|
||||||
|
|
||||||
|
// Remove client from listeners
|
||||||
|
ws.RemoveListener(client)
|
||||||
|
|
||||||
|
client.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Register our new client
|
||||||
|
ws.AddListener(client, blockID)
|
||||||
|
|
||||||
|
// TODO: Implement WebSocket message pump
|
||||||
|
// Simple message handling loop
|
||||||
|
for {
|
||||||
|
_, _, err := client.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR WebSocket onChange, blockID: %s, client: %s, err: %v", blockID, client.RemoteAddr(), err)
|
||||||
|
ws.RemoveListener(client)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WSServer) broadcastBlockChangeToWebsocketClients(blockIDs []string) {
|
||||||
|
for _, blockID := range blockIDs {
|
||||||
|
listeners := ws.GetListeners(blockID)
|
||||||
|
log.Printf("%d listener(s) for blockID: %s", len(listeners), blockID)
|
||||||
|
|
||||||
|
if listeners != nil {
|
||||||
|
var message = WebsocketMsg{
|
||||||
|
Action: "UPDATE_BLOCK",
|
||||||
|
BlockID: blockID,
|
||||||
|
}
|
||||||
|
for _, listener := range listeners {
|
||||||
|
log.Printf("Broadcast change, blockID: %s, remoteAddr: %s", blockID, listener.RemoteAddr())
|
||||||
|
err := listener.WriteJSON(message)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("broadcast error: %v", err)
|
||||||
|
listener.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
37
src/client/app.tsx
Normal file
37
src/client/app.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
BrowserRouter as Router,
|
||||||
|
Switch,
|
||||||
|
Route,
|
||||||
|
Link
|
||||||
|
} from "react-router-dom"
|
||||||
|
|
||||||
|
import LoginPage from './pages/loginPage'
|
||||||
|
import BoardPage from './pages/boardPage'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<div id="frame">
|
||||||
|
<div className="page-header">
|
||||||
|
<a href="/">OCTO</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main">
|
||||||
|
<Switch>
|
||||||
|
<Route path="/login">
|
||||||
|
<LoginPage />
|
||||||
|
</Route>
|
||||||
|
<Route path="/board">
|
||||||
|
<BoardPage />
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="overlay">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
)
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { IBlock, IProperty } from "./octoTypes"
|
import { IBlock } from "./octoTypes"
|
||||||
import { Utils } from "./utils"
|
import { Utils } from "./utils"
|
||||||
|
|
||||||
class Block implements IBlock {
|
class Block implements IBlock {
|
||||||
@ -9,7 +9,7 @@ class Block implements IBlock {
|
|||||||
icon?: string
|
icon?: string
|
||||||
url?: string
|
url?: string
|
||||||
order: number
|
order: number
|
||||||
properties: IProperty[] = []
|
properties: Record<string, string> = {}
|
||||||
createAt: number = Date.now()
|
createAt: number = Date.now()
|
||||||
updateAt: number = 0
|
updateAt: number = 0
|
||||||
deleteAt: number = 0
|
deleteAt: number = 0
|
||||||
@ -37,33 +37,20 @@ class Block implements IBlock {
|
|||||||
this.icon = block.icon
|
this.icon = block.icon
|
||||||
this.url = block.url
|
this.url = block.url
|
||||||
this.order = block.order
|
this.order = block.order
|
||||||
this.properties = block.properties ? block.properties.map((o: IProperty) => ({...o})) : [] // Deep clone
|
|
||||||
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
|
||||||
}
|
|
||||||
|
|
||||||
static getPropertyValue(block: IBlock, id: string): string | undefined {
|
if (Array.isArray(block.properties)) {
|
||||||
if (!block.properties) { return undefined }
|
// HACKHACK: Port from old schema
|
||||||
const property = block.properties.find( o => o.id === id )
|
this.properties = {}
|
||||||
if (!property) { return undefined }
|
for (const property of block.properties) {
|
||||||
return property.value
|
if (property.id) {
|
||||||
}
|
this.properties[property.id] = property.value
|
||||||
|
}
|
||||||
static setProperty(block: IBlock, id: string, value?: string) {
|
}
|
||||||
if (!block.properties) { block.properties = [] }
|
|
||||||
if (!value) {
|
|
||||||
// Remove property
|
|
||||||
block.properties = block.properties.filter( o => o.id !== id )
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const property = block.properties.find( o => o.id === id )
|
|
||||||
if (property) {
|
|
||||||
property.value = value
|
|
||||||
} else {
|
} else {
|
||||||
const newProperty: IProperty = { id, value }
|
this.properties = { ...block.properties || {} }
|
||||||
block.properties.push(newProperty)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,235 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import ReactDOM from "react-dom"
|
|
||||||
import { BoardTree } from "./boardTree"
|
|
||||||
import { BoardView } from "./boardView"
|
|
||||||
import { CardTree } from "./cardTree"
|
|
||||||
import { BoardComponent } from "./components/boardComponent"
|
|
||||||
import { CardDialog } from "./components/cardDialog"
|
|
||||||
import { FilterComponent } from "./components/filterComponent"
|
|
||||||
import { TableComponent } from "./components/tableComponent"
|
|
||||||
import { FlashMessage } from "./flashMessage"
|
|
||||||
import { Mutator } from "./mutator"
|
|
||||||
import { OctoClient } from "./octoClient"
|
|
||||||
import { OctoListener } from "./octoListener"
|
|
||||||
import { IBlock, IPageController } from "./octoTypes"
|
|
||||||
import { UndoManager } from "./undomanager"
|
|
||||||
import { Utils } from "./utils"
|
|
||||||
|
|
||||||
class BoardPage implements IPageController {
|
|
||||||
boardTitle: HTMLElement
|
|
||||||
mainBoardHeader: HTMLElement
|
|
||||||
mainBoardBody: HTMLElement
|
|
||||||
groupByButton: HTMLElement
|
|
||||||
groupByLabel: HTMLElement
|
|
||||||
|
|
||||||
boardId: string
|
|
||||||
viewId: string
|
|
||||||
|
|
||||||
boardTree: BoardTree
|
|
||||||
view: BoardView
|
|
||||||
|
|
||||||
updateTitleTimeout: number
|
|
||||||
updatePropertyLabelTimeout: number
|
|
||||||
|
|
||||||
shownCardTree: CardTree
|
|
||||||
|
|
||||||
private filterAnchorElement?: HTMLElement
|
|
||||||
private octo = new OctoClient()
|
|
||||||
private boardListener = new OctoListener()
|
|
||||||
private cardListener = new OctoListener()
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const queryString = new URLSearchParams(window.location.search)
|
|
||||||
if (!queryString.has("id")) {
|
|
||||||
// No id, redirect to home
|
|
||||||
window.location.href = "/"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.boardId = queryString.get("id")
|
|
||||||
this.viewId = queryString.get("v")
|
|
||||||
|
|
||||||
console.log(`BoardPage. boardId: ${this.boardId}`)
|
|
||||||
if (this.boardId) {
|
|
||||||
this.boardTree = new BoardTree(this.octo, this.boardId)
|
|
||||||
this.sync()
|
|
||||||
|
|
||||||
this.boardListener.open(this.boardId, (blockId: string) => {
|
|
||||||
console.log(`octoListener.onChanged: ${blockId}`)
|
|
||||||
this.sync()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Show error
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.addEventListener("keydown", async (e) => {
|
|
||||||
if (e.target !== document.body) { return }
|
|
||||||
|
|
||||||
if (e.keyCode === 90 && !e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) { // Cmd+Z
|
|
||||||
Utils.log(`Undo`)
|
|
||||||
const description = UndoManager.shared.undoDescription
|
|
||||||
await UndoManager.shared.undo()
|
|
||||||
if (description) {
|
|
||||||
FlashMessage.show(`Undo ${description}`)
|
|
||||||
} else {
|
|
||||||
FlashMessage.show(`Undo`)
|
|
||||||
}
|
|
||||||
} else if (e.keyCode === 90 && e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) { // Shift+Cmd+Z
|
|
||||||
Utils.log(`Redo`)
|
|
||||||
const description = UndoManager.shared.redoDescription
|
|
||||||
await UndoManager.shared.redo()
|
|
||||||
if (description) {
|
|
||||||
FlashMessage.show(`Redo ${description}`)
|
|
||||||
} else {
|
|
||||||
FlashMessage.show(`Redo`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { octo, boardTree } = this
|
|
||||||
const { board, activeView } = boardTree
|
|
||||||
const mutator = new Mutator(octo)
|
|
||||||
|
|
||||||
const rootElement = Utils.getElementById("main")
|
|
||||||
|
|
||||||
if (board) {
|
|
||||||
Utils.setFavicon(board.icon)
|
|
||||||
} else {
|
|
||||||
ReactDOM.render(
|
|
||||||
<div>Loading...</div>,
|
|
||||||
rootElement
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeView) {
|
|
||||||
document.title = `OCTO - ${board.title} | ${activeView.title}`
|
|
||||||
|
|
||||||
switch (activeView.viewType) {
|
|
||||||
case "board": {
|
|
||||||
ReactDOM.render(
|
|
||||||
<BoardComponent mutator={mutator} boardTree={this.boardTree} pageController={this} />,
|
|
||||||
rootElement
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "table": {
|
|
||||||
ReactDOM.render(
|
|
||||||
<TableComponent mutator={mutator} boardTree={this.boardTree} pageController={this} />,
|
|
||||||
rootElement
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
Utils.assertFailure(`render() Unhandled viewType: ${activeView.viewType}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (boardTree && boardTree.board && this.shownCardTree) {
|
|
||||||
ReactDOM.render(
|
|
||||||
<CardDialog mutator={mutator} boardTree={boardTree} cardTree={this.shownCardTree} onClose={() => { this.showCard(undefined) }}></CardDialog>,
|
|
||||||
Utils.getElementById("overlay")
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ReactDOM.render(
|
|
||||||
<div />,
|
|
||||||
Utils.getElementById("overlay")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ReactDOM.render(
|
|
||||||
<div>Loading...</div>,
|
|
||||||
rootElement
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.filterAnchorElement) {
|
|
||||||
const element = this.filterAnchorElement
|
|
||||||
const bodyRect = document.body.getBoundingClientRect()
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
// Show at bottom-left of element
|
|
||||||
const maxX = bodyRect.right - 420 - 100
|
|
||||||
const pageX = Math.min(maxX, rect.left - bodyRect.left)
|
|
||||||
const pageY = rect.bottom - bodyRect.top
|
|
||||||
|
|
||||||
ReactDOM.render(
|
|
||||||
<FilterComponent
|
|
||||||
mutator={mutator}
|
|
||||||
boardTree={boardTree}
|
|
||||||
pageX={pageX}
|
|
||||||
pageY={pageY}
|
|
||||||
onClose={() => {this.showFilter(undefined)}}
|
|
||||||
>
|
|
||||||
</FilterComponent>,
|
|
||||||
Utils.getElementById("modal")
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ReactDOM.render(<div />, Utils.getElementById("modal"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async sync() {
|
|
||||||
const { boardTree } = this
|
|
||||||
|
|
||||||
await boardTree.sync()
|
|
||||||
|
|
||||||
// Default to first view
|
|
||||||
if (!this.viewId) {
|
|
||||||
this.viewId = boardTree.views[0].id
|
|
||||||
}
|
|
||||||
|
|
||||||
boardTree.setActiveView(this.viewId)
|
|
||||||
// TODO: Handle error (viewId not found)
|
|
||||||
this.viewId = boardTree.activeView.id
|
|
||||||
|
|
||||||
console.log(`sync complete... title: ${boardTree.board.title}`)
|
|
||||||
|
|
||||||
this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPageController
|
|
||||||
|
|
||||||
async showCard(card: IBlock) {
|
|
||||||
this.cardListener.close()
|
|
||||||
|
|
||||||
if (card) {
|
|
||||||
const cardTree = new CardTree(this.octo, card.id)
|
|
||||||
await cardTree.sync()
|
|
||||||
this.shownCardTree = cardTree
|
|
||||||
|
|
||||||
this.cardListener = new OctoListener()
|
|
||||||
this.cardListener.open(card.id, async () => {
|
|
||||||
await cardTree.sync()
|
|
||||||
this.render()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.shownCardTree = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
showView(viewId: string) {
|
|
||||||
this.viewId = viewId
|
|
||||||
this.boardTree.setActiveView(this.viewId)
|
|
||||||
const newUrl = window.location.protocol + "//" + window.location.host + window.location.pathname + `?id=${encodeURIComponent(this.boardId)}&v=${encodeURIComponent(viewId)}`
|
|
||||||
window.history.pushState({ path: newUrl }, "", newUrl)
|
|
||||||
this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
showFilter(ahchorElement?: HTMLElement) {
|
|
||||||
this.filterAnchorElement = ahchorElement
|
|
||||||
this.render()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { BoardPage }
|
|
||||||
|
|
||||||
const _ = new BoardPage()
|
|
||||||
console.log("BoardPage")
|
|
@ -17,6 +17,7 @@ class BoardTree {
|
|||||||
activeView?: BoardView
|
activeView?: BoardView
|
||||||
groupByProperty?: IPropertyTemplate
|
groupByProperty?: IPropertyTemplate
|
||||||
|
|
||||||
|
private searchText?: string
|
||||||
private allCards: IBlock[] = []
|
private allCards: IBlock[] = []
|
||||||
get allBlocks(): IBlock[] {
|
get allBlocks(): IBlock[] {
|
||||||
return [this.board, ...this.views, ...this.allCards]
|
return [this.board, ...this.views, ...this.allCards]
|
||||||
@ -93,8 +94,18 @@ class BoardTree {
|
|||||||
this.applyFilterSortAndGroup()
|
this.applyFilterSortAndGroup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSearchText(): string | undefined {
|
||||||
|
return this.searchText
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchText(text?: string) {
|
||||||
|
this.searchText = text
|
||||||
|
this.applyFilterSortAndGroup()
|
||||||
|
}
|
||||||
|
|
||||||
applyFilterSortAndGroup() {
|
applyFilterSortAndGroup() {
|
||||||
this.cards = this.filterCards(this.allCards)
|
this.cards = this.filterCards(this.allCards)
|
||||||
|
this.cards = this.searchFilterCards(this.cards)
|
||||||
this.cards = this.sortCards(this.cards)
|
this.cards = this.sortCards(this.cards)
|
||||||
|
|
||||||
if (this.activeView.groupById) {
|
if (this.activeView.groupById) {
|
||||||
@ -104,6 +115,15 @@ class BoardTree {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private searchFilterCards(cards: IBlock[]) {
|
||||||
|
const searchText = this.searchText?.toLocaleLowerCase()
|
||||||
|
if (!searchText) { return cards.slice() }
|
||||||
|
|
||||||
|
return cards.filter(card => {
|
||||||
|
if (card.title?.toLocaleLowerCase().indexOf(searchText) !== -1) { return true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private setGroupByProperty(propertyId: string) {
|
private setGroupByProperty(propertyId: string) {
|
||||||
const { board } = this
|
const { board } = this
|
||||||
|
|
||||||
@ -125,16 +145,16 @@ class BoardTree {
|
|||||||
const groupByPropertyId = this.groupByProperty.id
|
const groupByPropertyId = this.groupByProperty.id
|
||||||
|
|
||||||
this.emptyGroupCards = this.cards.filter(o => {
|
this.emptyGroupCards = this.cards.filter(o => {
|
||||||
const property = o.properties.find(p => p.id === groupByPropertyId)
|
const propertyValue = o.properties[groupByPropertyId]
|
||||||
return !property || !property.value || !this.groupByProperty.options.find(option => option.value === property.value)
|
return !propertyValue || !this.groupByProperty.options.find(option => option.value === propertyValue)
|
||||||
})
|
})
|
||||||
|
|
||||||
const propertyOptions = this.groupByProperty.options || []
|
const propertyOptions = this.groupByProperty.options || []
|
||||||
for (const option of propertyOptions) {
|
for (const option of propertyOptions) {
|
||||||
const cards = this.cards
|
const cards = this.cards
|
||||||
.filter(o => {
|
.filter(o => {
|
||||||
const property = o.properties.find(p => p.id === groupByPropertyId)
|
const propertyValue = o.properties[groupByPropertyId]
|
||||||
return property && property.value === option.value
|
return propertyValue && propertyValue === option.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const group: Group = {
|
const group: Group = {
|
||||||
@ -200,10 +220,8 @@ class BoardTree {
|
|||||||
if (b.title && !a.title) { return 1 }
|
if (b.title && !a.title) { return 1 }
|
||||||
if (!a.title && !b.title) { return a.createAt - b.createAt }
|
if (!a.title && !b.title) { return a.createAt - b.createAt }
|
||||||
|
|
||||||
const aProperty = a.properties.find(o => o.id === sortPropertyId)
|
const aValue = a.properties[sortPropertyId] || ""
|
||||||
const bProperty = b.properties.find(o => o.id === sortPropertyId)
|
const bValue = b.properties[sortPropertyId] || ""
|
||||||
const aValue = aProperty ? aProperty.value : ""
|
|
||||||
const bValue = bProperty ? bProperty.value : ""
|
|
||||||
let result = 0
|
let result = 0
|
||||||
if (template.type === "select") {
|
if (template.type === "select") {
|
||||||
// Always put empty values at the bottom
|
// Always put empty values at the bottom
|
||||||
|
@ -1,106 +0,0 @@
|
|||||||
import { Archiver } from "./archiver"
|
|
||||||
import { Board } from "./board"
|
|
||||||
import { Mutator } from "./mutator"
|
|
||||||
import { OctoClient } from "./octoClient"
|
|
||||||
import { UndoManager } from "./undomanager"
|
|
||||||
import { Utils } from "./utils"
|
|
||||||
|
|
||||||
class BoardsPage {
|
|
||||||
boardsPanel: HTMLElement
|
|
||||||
|
|
||||||
boardId: string
|
|
||||||
boards: Board[]
|
|
||||||
|
|
||||||
octo = new OctoClient()
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// This is a placeholder page
|
|
||||||
|
|
||||||
const mainPanel = Utils.getElementById("main")
|
|
||||||
|
|
||||||
this.boardsPanel = mainPanel.appendChild(document.createElement("div"))
|
|
||||||
|
|
||||||
{
|
|
||||||
const addButton = document.body.appendChild(document.createElement("div"))
|
|
||||||
addButton.className = "octo-button"
|
|
||||||
addButton.innerText = "+ Add Board"
|
|
||||||
addButton.onclick = () => { this.addClicked() }
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.appendChild(document.createElement("br"))
|
|
||||||
|
|
||||||
{
|
|
||||||
const importButton = document.body.appendChild(document.createElement("div"))
|
|
||||||
importButton.className = "octo-button"
|
|
||||||
importButton.innerText = "Import archive"
|
|
||||||
importButton.onclick = async () => {
|
|
||||||
const octo = new OctoClient()
|
|
||||||
const mutator = new Mutator(octo, UndoManager.shared)
|
|
||||||
Archiver.importFullArchive(mutator, () => {
|
|
||||||
this.updateView()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const exportButton = document.body.appendChild(document.createElement("div"))
|
|
||||||
exportButton.className = "octo-button"
|
|
||||||
exportButton.innerText = "Export archive"
|
|
||||||
exportButton.onclick = () => {
|
|
||||||
const octo = new OctoClient()
|
|
||||||
const mutator = new Mutator(octo, UndoManager.shared)
|
|
||||||
Archiver.exportFullArchive(mutator)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateView()
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBoardData() {
|
|
||||||
const boards = this.octo.getBlocks(null, "board")
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateView() {
|
|
||||||
const { boardsPanel } = this
|
|
||||||
|
|
||||||
boardsPanel.innerText = ""
|
|
||||||
|
|
||||||
const boards = await this.octo.getBlocks(null, "board")
|
|
||||||
for (const board of boards) {
|
|
||||||
const p = boardsPanel.appendChild(document.createElement("p"))
|
|
||||||
const a = p.appendChild(document.createElement("a"))
|
|
||||||
a.style.padding = "5px 10px"
|
|
||||||
a.style.fontSize = "20px"
|
|
||||||
a.href = `./board?id=${encodeURIComponent(board.id)}`
|
|
||||||
|
|
||||||
if (board.icon) {
|
|
||||||
const icon = a.appendChild(document.createElement("span"))
|
|
||||||
icon.className = "octo-icon"
|
|
||||||
icon.style.marginRight = "10px"
|
|
||||||
icon.innerText = board.icon
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = a.appendChild(document.createElement("b"))
|
|
||||||
const updatedDate = new Date(board.updateAt)
|
|
||||||
title.innerText = board.title
|
|
||||||
const details = a.appendChild(document.createElement("span"))
|
|
||||||
details.style.fontSize = "15px"
|
|
||||||
details.style.color = "#909090"
|
|
||||||
details.style.marginLeft = "10px"
|
|
||||||
details.innerText = ` ${Utils.displayDate(updatedDate)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`updateView: ${boards.length} board(s).`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async addClicked() {
|
|
||||||
const board = new Board()
|
|
||||||
await this.octo.insertBlock(board)
|
|
||||||
await this.updateView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export = BoardsPage
|
|
||||||
|
|
||||||
const _ = new BoardsPage()
|
|
||||||
console.log("boardsView")
|
|
@ -1,7 +1,7 @@
|
|||||||
import { IPropertyTemplate } from "./board"
|
import { IPropertyTemplate } from "./board"
|
||||||
import { FilterClause } from "./filterClause"
|
import { FilterClause } from "./filterClause"
|
||||||
import { FilterGroup } from "./filterGroup"
|
import { FilterGroup } from "./filterGroup"
|
||||||
import { IBlock, IProperty } from "./octoTypes"
|
import { IBlock } from "./octoTypes"
|
||||||
import { Utils } from "./utils"
|
import { Utils } from "./utils"
|
||||||
|
|
||||||
class CardFilter {
|
class CardFilter {
|
||||||
@ -39,8 +39,7 @@ class CardFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static isClauseMet(filter: FilterClause, templates: IPropertyTemplate[], card: IBlock): boolean {
|
static isClauseMet(filter: FilterClause, templates: IPropertyTemplate[], card: IBlock): boolean {
|
||||||
const property = card.properties.find(o => o.id === filter.propertyId)
|
const value = card.properties[filter.propertyId]
|
||||||
const value = property?.value
|
|
||||||
switch (filter.condition) {
|
switch (filter.condition) {
|
||||||
case "includes": {
|
case "includes": {
|
||||||
if (filter.values.length < 1) { break } // No values = ignore clause (always met)
|
if (filter.values.length < 1) { break } // No values = ignore clause (always met)
|
||||||
@ -61,7 +60,7 @@ class CardFilter {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
static propertiesThatMeetFilterGroup(filterGroup: FilterGroup, templates: IPropertyTemplate[]): IProperty[] {
|
static propertiesThatMeetFilterGroup(filterGroup: FilterGroup, templates: IPropertyTemplate[]): Record<string, any> {
|
||||||
// TODO: Handle filter groups
|
// TODO: Handle filter groups
|
||||||
const filters = filterGroup.filters.filter(o => !FilterGroup.isAnInstanceOf(o))
|
const filters = filterGroup.filters.filter(o => !FilterGroup.isAnInstanceOf(o))
|
||||||
if (filters.length < 1) { return [] }
|
if (filters.length < 1) { return [] }
|
||||||
@ -75,7 +74,7 @@ class CardFilter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static propertyThatMeetsFilterClause(filterClause: FilterClause, templates: IPropertyTemplate[]): IProperty {
|
static propertyThatMeetsFilterClause(filterClause: FilterClause, templates: IPropertyTemplate[]): { id: string, value?: string } {
|
||||||
const template = templates.find(o => o.id === filterClause.propertyId)
|
const template = templates.find(o => o.id === filterClause.propertyId)
|
||||||
switch (filterClause.condition) {
|
switch (filterClause.condition) {
|
||||||
case "includes": {
|
case "includes": {
|
||||||
|
13
src/client/components/__snapshots__/rootPortal.test.tsx.snap
Normal file
13
src/client/components/__snapshots__/rootPortal.test.tsx.snap
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`components/RootPortal should match snapshot 1`] = `
|
||||||
|
<div
|
||||||
|
id="root-portal"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
Testing Portal
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
@ -4,40 +4,52 @@ import { Block } from "../block"
|
|||||||
import { BlockIcons } from "../blockIcons"
|
import { BlockIcons } from "../blockIcons"
|
||||||
import { IPropertyOption } from "../board"
|
import { IPropertyOption } from "../board"
|
||||||
import { BoardTree } from "../boardTree"
|
import { BoardTree } from "../boardTree"
|
||||||
import { ISortOption } from "../boardView"
|
|
||||||
import { CardFilter } from "../cardFilter"
|
import { CardFilter } from "../cardFilter"
|
||||||
|
import ViewMenu from "../components/viewMenu"
|
||||||
import { Constants } from "../constants"
|
import { Constants } from "../constants"
|
||||||
import { Menu } from "../menu"
|
import { Menu as OldMenu } from "../menu"
|
||||||
import { Mutator } from "../mutator"
|
import { Mutator } from "../mutator"
|
||||||
import { IBlock, IPageController } from "../octoTypes"
|
import { IBlock } from "../octoTypes"
|
||||||
import { OctoUtils } from "../octoUtils"
|
import { OctoUtils } from "../octoUtils"
|
||||||
import { Utils } from "../utils"
|
import { Utils } from "../utils"
|
||||||
import { BoardCard } from "./boardCard"
|
import { BoardCard } from "./boardCard"
|
||||||
import { BoardColumn } from "./boardColumn"
|
import { BoardColumn } from "./boardColumn"
|
||||||
import { Button } from "./button"
|
import Button from "./button"
|
||||||
import { Editable } from "./editable"
|
import { Editable } from "./editable"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mutator: Mutator,
|
mutator: Mutator,
|
||||||
boardTree?: BoardTree
|
boardTree?: BoardTree
|
||||||
pageController: IPageController
|
showView: (id: string) => void
|
||||||
|
showCard: (card: IBlock) => void
|
||||||
|
showFilter: (el: HTMLElement) => void
|
||||||
|
setSearchText: (text: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
isHoverOnCover: boolean
|
isHoverOnCover: boolean
|
||||||
|
isSearching: boolean
|
||||||
|
viewMenu: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
class BoardComponent extends React.Component<Props, State> {
|
class BoardComponent extends React.Component<Props, State> {
|
||||||
private draggedCard: IBlock
|
private draggedCard: IBlock
|
||||||
private draggedHeaderOption: IPropertyOption
|
private draggedHeaderOption: IPropertyOption
|
||||||
|
private searchFieldRef = React.createRef<Editable>()
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props)
|
super(props)
|
||||||
this.state = { isHoverOnCover: false }
|
this.state = { isHoverOnCover: false, isSearching: !!this.props.boardTree?.getSearchText(), viewMenu: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevPros: Props, prevState: State) {
|
||||||
|
if (this.state.isSearching && !prevState.isSearching) {
|
||||||
|
this.searchFieldRef.current.focus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { mutator, boardTree, pageController } = this.props
|
const { mutator, boardTree, showView } = this.props
|
||||||
|
|
||||||
if (!boardTree || !boardTree.board) {
|
if (!boardTree || !boardTree.board) {
|
||||||
return (
|
return (
|
||||||
@ -81,15 +93,38 @@ class BoardComponent extends React.Component<Props, State> {
|
|||||||
<div className="octo-board">
|
<div className="octo-board">
|
||||||
<div className="octo-controls">
|
<div className="octo-controls">
|
||||||
<Editable style={{ color: "#000000", fontWeight: 600 }} text={activeView.title} placeholderText="Untitled View" onChanged={(text) => { mutator.changeTitle(activeView, text) }} />
|
<Editable style={{ color: "#000000", fontWeight: 600 }} text={activeView.title} placeholderText="Untitled View" onChanged={(text) => { mutator.changeTitle(activeView, text) }} />
|
||||||
<div className="octo-button" style={{ color: "#000000", fontWeight: 600 }} onClick={(e) => { OctoUtils.showViewMenu(e, mutator, boardTree, pageController) }}><div className="imageDropdown"></div></div>
|
<div
|
||||||
|
className="octo-button"
|
||||||
|
style={{ color: "#000000", fontWeight: 600 }}
|
||||||
|
onClick={() => this.setState({ viewMenu: true })}
|
||||||
|
>
|
||||||
|
{this.state.viewMenu &&
|
||||||
|
<ViewMenu
|
||||||
|
board={board}
|
||||||
|
onClose={() => this.setState({ viewMenu: false })}
|
||||||
|
mutator={mutator}
|
||||||
|
boardTree={boardTree}
|
||||||
|
showView={showView}
|
||||||
|
/>}
|
||||||
|
<div className="imageDropdown"></div>
|
||||||
|
</div>
|
||||||
<div className="octo-spacer"></div>
|
<div className="octo-spacer"></div>
|
||||||
<div className="octo-button" onClick={(e) => { this.propertiesClicked(e) }}>Properties</div>
|
<div className="octo-button" onClick={(e) => { this.propertiesClicked(e) }}>Properties</div>
|
||||||
<div className="octo-button" id="groupByButton" onClick={(e) => { this.groupByClicked(e) }}>
|
<div className="octo-button" id="groupByButton" onClick={(e) => { this.groupByClicked(e) }}>
|
||||||
Group by <span style={groupByStyle} id="groupByLabel">{boardTree.groupByProperty?.name}</span>
|
Group by <span style={groupByStyle} id="groupByLabel">{boardTree.groupByProperty?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={ hasFilter ? "octo-button active" : "octo-button"} onClick={(e) => { this.filterClicked(e) }}>Filter</div>
|
<div className={hasFilter ? "octo-button active" : "octo-button"} onClick={(e) => { this.filterClicked(e) }}>Filter</div>
|
||||||
<div className={ hasSort ? "octo-button active" : "octo-button"} onClick={(e) => { this.sortClicked(e) }}>Sort</div>
|
<div className={hasSort ? "octo-button active" : "octo-button"} onClick={(e) => { OctoUtils.showSortMenu(e, mutator, boardTree) }}>Sort</div>
|
||||||
<div className="octo-button">Search</div>
|
{this.state.isSearching
|
||||||
|
? <Editable
|
||||||
|
ref={this.searchFieldRef}
|
||||||
|
text={boardTree.getSearchText()}
|
||||||
|
placeholderText="Search text"
|
||||||
|
style={{ color: "#000000" }}
|
||||||
|
onChanged={(text) => { this.searchChanged(text) }}
|
||||||
|
onKeyDown={(e) => { this.onSearchKeyDown(e) }}></Editable>
|
||||||
|
: <div className="octo-button" onClick={() => { this.setState({ ...this.state, isSearching: true }) }}>Search</div>
|
||||||
|
}
|
||||||
<div className="octo-button" onClick={(e) => { this.optionsClicked(e) }}><div className="imageOptions" /></div>
|
<div className="octo-button" onClick={(e) => { this.optionsClicked(e) }}><div className="imageOptions" /></div>
|
||||||
<div className="octo-button filled" onClick={() => { this.addCard(undefined) }}>New</div>
|
<div className="octo-button filled" onClick={() => { this.addCard(undefined) }}>New</div>
|
||||||
</div>
|
</div>
|
||||||
@ -186,11 +221,11 @@ class BoardComponent extends React.Component<Props, State> {
|
|||||||
const { mutator, boardTree } = this.props
|
const { mutator, boardTree } = this.props
|
||||||
const { board } = boardTree
|
const { board } = boardTree
|
||||||
|
|
||||||
Menu.shared.options = [
|
OldMenu.shared.options = [
|
||||||
{ id: "random", name: "Random" },
|
{ id: "random", name: "Random" },
|
||||||
{ id: "remove", name: "Remove Icon" },
|
{ id: "remove", name: "Remove Icon" },
|
||||||
]
|
]
|
||||||
Menu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
OldMenu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
||||||
switch (optionId) {
|
switch (optionId) {
|
||||||
case "remove":
|
case "remove":
|
||||||
mutator.changeIcon(board, undefined, "remove icon")
|
mutator.changeIcon(board, undefined, "remove icon")
|
||||||
@ -201,13 +236,13 @@ class BoardComponent extends React.Component<Props, State> {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
OldMenu.shared.showAtElement(e.target as HTMLElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
async showCard(card?: IBlock) {
|
async showCard(card?: IBlock) {
|
||||||
console.log(`showCard: ${card?.title}`)
|
console.log(`showCard: ${card?.title}`)
|
||||||
|
|
||||||
await this.props.pageController.showCard(card)
|
await this.props.showCard(card)
|
||||||
}
|
}
|
||||||
|
|
||||||
async addCard(groupByValue?: string) {
|
async addCard(groupByValue?: string) {
|
||||||
@ -217,7 +252,7 @@ class BoardComponent extends React.Component<Props, State> {
|
|||||||
const properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
|
const properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
|
||||||
const card = new Block({ type: "card", parentId: boardTree.board.id, properties })
|
const card = new Block({ type: "card", parentId: boardTree.board.id, properties })
|
||||||
if (boardTree.groupByProperty) {
|
if (boardTree.groupByProperty) {
|
||||||
Block.setProperty(card, boardTree.groupByProperty.id, groupByValue)
|
card.properties[boardTree.groupByProperty.id] = groupByValue
|
||||||
}
|
}
|
||||||
await mutator.insertBlock(card, "add card", async () => { await this.showCard(card) }, async () => { await this.showCard(undefined) })
|
await mutator.insertBlock(card, "add card", async () => { await this.showCard(card) }, async () => { await this.showCard(undefined) })
|
||||||
}
|
}
|
||||||
@ -231,12 +266,12 @@ class BoardComponent extends React.Component<Props, State> {
|
|||||||
async valueOptionClicked(e: React.MouseEvent<HTMLElement>, option: IPropertyOption) {
|
async valueOptionClicked(e: React.MouseEvent<HTMLElement>, option: IPropertyOption) {
|
||||||
const { mutator, boardTree } = this.props
|
const { mutator, boardTree } = this.props
|
||||||
|
|
||||||
Menu.shared.options = [
|
OldMenu.shared.options = [
|
||||||
{ id: "delete", name: "Delete" },
|
{ id: "delete", name: "Delete" },
|
||||||
{ id: "", name: "", type: "separator" },
|
{ id: "", name: "", type: "separator" },
|
||||||
...Constants.menuColors
|
...Constants.menuColors
|
||||||
]
|
]
|
||||||
Menu.shared.onMenuClicked = async (optionId: string, type?: string) => {
|
OldMenu.shared.onMenuClicked = async (optionId: string, type?: string) => {
|
||||||
switch (optionId) {
|
switch (optionId) {
|
||||||
case "delete":
|
case "delete":
|
||||||
console.log(`Delete property value: ${option.value}`)
|
console.log(`Delete property value: ${option.value}`)
|
||||||
@ -250,56 +285,57 @@ class BoardComponent extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
OldMenu.shared.showAtElement(e.target as HTMLElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
private filterClicked(e: React.MouseEvent) {
|
private filterClicked(e: React.MouseEvent) {
|
||||||
const { pageController } = this.props
|
this.props.showFilter(e.target as HTMLElement)
|
||||||
pageController.showFilter(e.target as HTMLElement)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sortClicked(e: React.MouseEvent) {
|
|
||||||
const { mutator, boardTree } = this.props
|
|
||||||
const { activeView } = boardTree
|
|
||||||
const { sortOptions } = activeView
|
|
||||||
const sortOption = sortOptions.length > 0 ? sortOptions[0] : undefined
|
|
||||||
|
|
||||||
const propertyTemplates = boardTree.board.cardProperties
|
|
||||||
Menu.shared.options = propertyTemplates.map((o) => { return { id: o.id, name: o.name } })
|
|
||||||
Menu.shared.onMenuClicked = async (propertyId: string) => {
|
|
||||||
let newSortOptions: ISortOption[] = []
|
|
||||||
if (sortOption && sortOption.propertyId === propertyId) {
|
|
||||||
// Already sorting by name, so reverse it
|
|
||||||
newSortOptions = [
|
|
||||||
{ propertyId, reversed: !sortOption.reversed }
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
newSortOptions = [
|
|
||||||
{ propertyId, reversed: false }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
await mutator.changeViewSortOptions(activeView, newSortOptions)
|
|
||||||
}
|
|
||||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async optionsClicked(e: React.MouseEvent) {
|
private async optionsClicked(e: React.MouseEvent) {
|
||||||
const { boardTree } = this.props
|
const { boardTree } = this.props
|
||||||
|
|
||||||
Menu.shared.options = [
|
OldMenu.shared.options = [
|
||||||
{ id: "exportBoardArchive", name: "Export board archive" },
|
{ id: "exportBoardArchive", name: "Export board archive" },
|
||||||
|
{ id: "testAdd100Cards", name: "TEST: Add 100 cards" },
|
||||||
|
{ id: "testAdd1000Cards", name: "TEST: Add 1,000 cards" },
|
||||||
]
|
]
|
||||||
|
|
||||||
Menu.shared.onMenuClicked = async (id: string) => {
|
OldMenu.shared.onMenuClicked = async (id: string) => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "exportBoardArchive": {
|
case "exportBoardArchive": {
|
||||||
Archiver.exportBoardTree(boardTree)
|
Archiver.exportBoardTree(boardTree)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case "testAdd100Cards": {
|
||||||
|
this.testAddCards(100)
|
||||||
|
}
|
||||||
|
case "testAdd1000Cards": {
|
||||||
|
this.testAddCards(1000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
OldMenu.shared.showAtElement(e.target as HTMLElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testAddCards(count: number) {
|
||||||
|
const { mutator, boardTree } = this.props
|
||||||
|
const { board, activeView } = boardTree
|
||||||
|
|
||||||
|
let optionIndex = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
|
||||||
|
const card = new Block({ type: "card", parentId: boardTree.board.id, properties })
|
||||||
|
if (boardTree.groupByProperty && boardTree.groupByProperty.options.length > 0) {
|
||||||
|
// Cycle through options
|
||||||
|
const option = boardTree.groupByProperty.options[optionIndex]
|
||||||
|
optionIndex = (optionIndex + 1) % boardTree.groupByProperty.options.length
|
||||||
|
card.properties[boardTree.groupByProperty.id] = option.value
|
||||||
|
card.title = `Test Card ${i + 1}`
|
||||||
|
}
|
||||||
|
await mutator.insertBlock(card, "test add card")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async propertiesClicked(e: React.MouseEvent) {
|
private async propertiesClicked(e: React.MouseEvent) {
|
||||||
@ -307,12 +343,12 @@ class BoardComponent extends React.Component<Props, State> {
|
|||||||
const { activeView } = boardTree
|
const { activeView } = boardTree
|
||||||
|
|
||||||
const selectProperties = boardTree.board.cardProperties
|
const selectProperties = boardTree.board.cardProperties
|
||||||
Menu.shared.options = selectProperties.map((o) => {
|
OldMenu.shared.options = selectProperties.map((o) => {
|
||||||
const isVisible = activeView.visiblePropertyIds.includes(o.id)
|
const isVisible = activeView.visiblePropertyIds.includes(o.id)
|
||||||
return { id: o.id, name: o.name, type: "switch", isOn: isVisible }
|
return { id: o.id, name: o.name, type: "switch", isOn: isVisible }
|
||||||
})
|
})
|
||||||
|
|
||||||
Menu.shared.onMenuToggled = async (id: string, isOn: boolean) => {
|
OldMenu.shared.onMenuToggled = async (id: string, isOn: boolean) => {
|
||||||
const property = selectProperties.find(o => o.id === id)
|
const property = selectProperties.find(o => o.id === id)
|
||||||
Utils.assertValue(property)
|
Utils.assertValue(property)
|
||||||
Utils.log(`Toggle property ${property.name} ${isOn}`)
|
Utils.log(`Toggle property ${property.name} ${isOn}`)
|
||||||
@ -325,20 +361,20 @@ class BoardComponent extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
await mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
|
await mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
|
||||||
}
|
}
|
||||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
OldMenu.shared.showAtElement(e.target as HTMLElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async groupByClicked(e: React.MouseEvent) {
|
private async groupByClicked(e: React.MouseEvent) {
|
||||||
const { mutator, boardTree } = this.props
|
const { mutator, boardTree } = this.props
|
||||||
|
|
||||||
const selectProperties = boardTree.board.cardProperties.filter(o => o.type === "select")
|
const selectProperties = boardTree.board.cardProperties.filter(o => o.type === "select")
|
||||||
Menu.shared.options = selectProperties.map((o) => { return { id: o.id, name: o.name } })
|
OldMenu.shared.options = selectProperties.map((o) => { return { id: o.id, name: o.name } })
|
||||||
Menu.shared.onMenuClicked = async (command: string) => {
|
OldMenu.shared.onMenuClicked = async (command: string) => {
|
||||||
if (boardTree.activeView.groupById === command) { return }
|
if (boardTree.activeView.groupById === command) { return }
|
||||||
|
|
||||||
await mutator.changeViewGroupById(boardTree.activeView, command)
|
await mutator.changeViewGroupById(boardTree.activeView, command)
|
||||||
}
|
}
|
||||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
OldMenu.shared.showAtElement(e.target as HTMLElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
async addGroupClicked() {
|
async addGroupClicked() {
|
||||||
@ -364,7 +400,7 @@ class BoardComponent extends React.Component<Props, State> {
|
|||||||
|
|
||||||
if (draggedCard) {
|
if (draggedCard) {
|
||||||
Utils.log(`ondrop. Card: ${draggedCard.title}, column: ${propertyValue}`)
|
Utils.log(`ondrop. Card: ${draggedCard.title}, column: ${propertyValue}`)
|
||||||
const oldValue = Block.getPropertyValue(draggedCard, boardTree.groupByProperty.id)
|
const oldValue = draggedCard.properties[boardTree.groupByProperty.id]
|
||||||
if (propertyValue !== oldValue) {
|
if (propertyValue !== oldValue) {
|
||||||
await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty.id, propertyValue, "drag card")
|
await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty.id, propertyValue, "drag card")
|
||||||
}
|
}
|
||||||
@ -380,6 +416,19 @@ class BoardComponent extends React.Component<Props, State> {
|
|||||||
await mutator.changePropertyOptionOrder(board, boardTree.groupByProperty, draggedHeaderOption, destIndex)
|
await mutator.changePropertyOptionOrder(board, boardTree.groupByProperty, draggedHeaderOption, destIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSearchKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.keyCode === 27) { // ESC: Clear search
|
||||||
|
this.searchFieldRef.current.text = ""
|
||||||
|
this.setState({ ...this.state, isSearching: false })
|
||||||
|
this.props.setSearchText(undefined)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchChanged(text?: string) {
|
||||||
|
this.props.setSearchText(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { BoardComponent }
|
export { BoardComponent }
|
||||||
|
17
src/client/components/button.scss
Normal file
17
src/client/components/button.scss
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
.Button {
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0 5px;
|
||||||
|
min-width: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
transition: background 100ms ease-out 0s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #eeeeee;
|
||||||
|
}
|
||||||
|
.octo-hovercontrol {
|
||||||
|
background: rgb(239, 239, 238);
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
|
import './button.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
|
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
@ -8,13 +10,13 @@ type Props = {
|
|||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
class Button extends React.Component<Props> {
|
export default class Button extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
const style = {...this.props.style, backgroundColor: this.props.backgroundColor}
|
const style = {...this.props.style, backgroundColor: this.props.backgroundColor}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={this.props.onClick}
|
onClick={this.props.onClick}
|
||||||
className="octo-button"
|
className="Button octo-button"
|
||||||
style={style}
|
style={style}
|
||||||
title={this.props.title}>
|
title={this.props.title}>
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
@ -22,5 +24,3 @@ class Button extends React.Component<Props> {
|
|||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button }
|
|
||||||
|
@ -9,7 +9,7 @@ import { IBlock } from "../octoTypes"
|
|||||||
import { OctoUtils } from "../octoUtils"
|
import { OctoUtils } from "../octoUtils"
|
||||||
import { PropertyMenu } from "../propertyMenu"
|
import { PropertyMenu } from "../propertyMenu"
|
||||||
import { Utils } from "../utils"
|
import { Utils } from "../utils"
|
||||||
import { Button } from "./button"
|
import Button from "./button"
|
||||||
import { Editable } from "./editable"
|
import { Editable } from "./editable"
|
||||||
import { MarkdownEditor } from "./markdownEditor"
|
import { MarkdownEditor } from "./markdownEditor"
|
||||||
|
|
||||||
|
@ -102,7 +102,6 @@ class Editable extends React.Component<Props, State> {
|
|||||||
this.text = newText
|
this.text = newText
|
||||||
|
|
||||||
this.elementRef.current.classList.remove("active")
|
this.elementRef.current.classList.remove("active")
|
||||||
|
|
||||||
if (onBlur) { onBlur() }
|
if (onBlur) { onBlur() }
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
16
src/client/components/pageHeader.tsx
Normal file
16
src/client/components/pageHeader.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
}
|
||||||
|
|
||||||
|
class PageHeader extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="page-header">
|
||||||
|
<a href="/">OCTO</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PageHeader }
|
30
src/client/components/rootPortal.test.tsx
Normal file
30
src/client/components/rootPortal.test.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {render} from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
import RootPortal from './rootPortal';
|
||||||
|
|
||||||
|
describe('components/RootPortal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Quick fix to disregard console error when unmounting a component
|
||||||
|
console.error = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should match snapshot', () => {
|
||||||
|
const rootPortalDiv = document.createElement('div');
|
||||||
|
rootPortalDiv.id = 'root-portal';
|
||||||
|
|
||||||
|
const {getByText, container} = render(
|
||||||
|
<RootPortal>
|
||||||
|
<div>{'Testing Portal'}</div>
|
||||||
|
</RootPortal>,
|
||||||
|
{container: document.body.appendChild(rootPortalDiv)},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Testing Portal')).toBeVisible();
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
44
src/client/components/rootPortal.tsx
Normal file
44
src/client/components/rootPortal.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class RootPortal extends React.PureComponent<Props> {
|
||||||
|
el: HTMLDivElement
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
children: PropTypes.node,
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.el = document.createElement('div')
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const rootPortal = document.getElementById('root-portal')
|
||||||
|
if (rootPortal) {
|
||||||
|
rootPortal.appendChild(this.el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
const rootPortal = document.getElementById('root-portal')
|
||||||
|
if (rootPortal) {
|
||||||
|
rootPortal.removeChild(this.el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
this.props.children,
|
||||||
|
this.el,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
129
src/client/components/sidebar.tsx
Normal file
129
src/client/components/sidebar.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Archiver } from "../archiver"
|
||||||
|
import { Board } from "../board"
|
||||||
|
import { BoardTree } from "../boardTree"
|
||||||
|
import { Menu, MenuOption } from "../menu"
|
||||||
|
import { Mutator } from "../mutator"
|
||||||
|
import { IPageController } from "../octoTypes"
|
||||||
|
import { WorkspaceTree } from "../workspaceTree"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mutator: Mutator
|
||||||
|
showBoard: (id: string) => void
|
||||||
|
workspaceTree: WorkspaceTree,
|
||||||
|
boardTree?: BoardTree
|
||||||
|
}
|
||||||
|
|
||||||
|
class Sidebar extends React.Component<Props> {
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { workspaceTree } = this.props
|
||||||
|
if (!workspaceTree) {
|
||||||
|
return <div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const { boards } = workspaceTree
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="octo-sidebar">
|
||||||
|
{
|
||||||
|
boards.map(board => {
|
||||||
|
const displayTitle = board.title || "(Untitled Board)"
|
||||||
|
return (
|
||||||
|
<div key={board.id} className="octo-sidebar-item octo-hover-container">
|
||||||
|
<div className="octo-sidebar-title" onClick={() => { this.boardClicked(board) }}>{board.icon ? `${board.icon} ${displayTitle}` : displayTitle}</div>
|
||||||
|
<div className="octo-spacer"></div>
|
||||||
|
<div className="octo-button square octo-hover-item" onClick={(e) => { this.showOptions(e, board) }}><div className="imageOptions" /></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<div className="octo-button" onClick={() => { this.addBoardClicked() }}>+ Add Board</div>
|
||||||
|
|
||||||
|
<div className="octo-spacer"></div>
|
||||||
|
|
||||||
|
<div className="octo-button" onClick={(e) => { this.settingsClicked(e) }}>Settings</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private showOptions(e: React.MouseEvent, board: Board) {
|
||||||
|
const { mutator, showBoard, workspaceTree } = this.props
|
||||||
|
const { boards } = workspaceTree
|
||||||
|
|
||||||
|
const options: MenuOption[] = []
|
||||||
|
|
||||||
|
const nextBoardId = boards.length > 1 ? boards.find(o => o.id !== board.id).id : undefined
|
||||||
|
if (nextBoardId) {
|
||||||
|
options.push({ id: "delete", name: "Delete board" })
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu.shared.options = options
|
||||||
|
Menu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
||||||
|
switch (optionId) {
|
||||||
|
case "delete": {
|
||||||
|
mutator.deleteBlock(
|
||||||
|
board,
|
||||||
|
"delete block",
|
||||||
|
async () => { showBoard(nextBoardId!) },
|
||||||
|
async () => { showBoard(board.id) },
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
private settingsClicked(e: React.MouseEvent) {
|
||||||
|
const { mutator } = this.props
|
||||||
|
|
||||||
|
Menu.shared.options = [
|
||||||
|
{ id: "import", name: "Import Archive" },
|
||||||
|
{ id: "export", name: "Export Archive" },
|
||||||
|
]
|
||||||
|
Menu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
||||||
|
switch (optionId) {
|
||||||
|
case "import": {
|
||||||
|
Archiver.importFullArchive(mutator, () => {
|
||||||
|
this.forceUpdate()
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "export": {
|
||||||
|
Archiver.exportFullArchive(mutator)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HACKHACK: Show menu above (TODO: refactor menu code to do this automatically)
|
||||||
|
const element = e.target as HTMLElement
|
||||||
|
const bodyRect = document.body.getBoundingClientRect()
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
Menu.shared.showAt(rect.left - bodyRect.left + 20, rect.top - bodyRect.top - 30 * Menu.shared.options.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
private boardClicked(board: Board) {
|
||||||
|
this.props.showBoard(board.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async addBoardClicked() {
|
||||||
|
const { mutator, boardTree, showBoard } = this.props
|
||||||
|
|
||||||
|
const oldBoardId = boardTree?.board?.id
|
||||||
|
const board = new Board()
|
||||||
|
await mutator.insertBlock(
|
||||||
|
board,
|
||||||
|
"add board",
|
||||||
|
async () => { showBoard(board.id) },
|
||||||
|
async () => { if (oldBoardId) { showBoard(oldBoardId) } })
|
||||||
|
|
||||||
|
await mutator.insertBlock(board)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Sidebar }
|
@ -4,39 +4,51 @@ import { Block } from "../block"
|
|||||||
import { BlockIcons } from "../blockIcons"
|
import { BlockIcons } from "../blockIcons"
|
||||||
import { IPropertyTemplate } from "../board"
|
import { IPropertyTemplate } from "../board"
|
||||||
import { BoardTree } from "../boardTree"
|
import { BoardTree } from "../boardTree"
|
||||||
import { ISortOption } from "../boardView"
|
|
||||||
import { CsvExporter } from "../csvExporter"
|
import { CsvExporter } from "../csvExporter"
|
||||||
import { Menu } from "../menu"
|
import ViewMenu from "../components/viewMenu"
|
||||||
|
import { Menu as OldMenu } from "../menu"
|
||||||
import { Mutator } from "../mutator"
|
import { Mutator } from "../mutator"
|
||||||
import { IBlock, IPageController } from "../octoTypes"
|
import { IBlock } from "../octoTypes"
|
||||||
import { OctoUtils } from "../octoUtils"
|
import { OctoUtils } from "../octoUtils"
|
||||||
import { Utils } from "../utils"
|
import { Utils } from "../utils"
|
||||||
import { Button } from "./button"
|
import Button from "./button"
|
||||||
import { Editable } from "./editable"
|
import { Editable } from "./editable"
|
||||||
import { TableRow } from "./tableRow"
|
import { TableRow } from "./tableRow"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mutator: Mutator,
|
mutator: Mutator,
|
||||||
boardTree?: BoardTree
|
boardTree?: BoardTree
|
||||||
pageController: IPageController
|
showView: (id: string) => void
|
||||||
|
showCard: (card: IBlock) => void
|
||||||
|
showFilter: (el: HTMLElement) => void
|
||||||
|
setSearchText: (text: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
isHoverOnCover: boolean
|
isHoverOnCover: boolean
|
||||||
|
isSearching: boolean
|
||||||
|
viewMenu: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
private searchFieldRef = React.createRef<Editable>()
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props)
|
super(props)
|
||||||
this.state = { isHoverOnCover: false }
|
this.state = { isHoverOnCover: false, isSearching: !!this.props.boardTree?.getSearchText(), viewMenu: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevPros: Props, prevState: State) {
|
||||||
|
if (this.state.isSearching && !prevState.isSearching) {
|
||||||
|
this.searchFieldRef.current.focus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { mutator, boardTree, pageController } = this.props
|
const { mutator, boardTree, showView } = this.props
|
||||||
|
|
||||||
if (!boardTree || !boardTree.board) {
|
if (!boardTree || !boardTree.board) {
|
||||||
return (
|
return (
|
||||||
@ -78,12 +90,35 @@ class TableComponent extends React.Component<Props, State> {
|
|||||||
<div className="octo-table">
|
<div className="octo-table">
|
||||||
<div className="octo-controls">
|
<div className="octo-controls">
|
||||||
<Editable style={{ color: "#000000", fontWeight: 600 }} text={activeView.title} placeholderText="Untitled View" onChanged={(text) => { mutator.changeTitle(activeView, text) }} />
|
<Editable style={{ color: "#000000", fontWeight: 600 }} text={activeView.title} placeholderText="Untitled View" onChanged={(text) => { mutator.changeTitle(activeView, text) }} />
|
||||||
<div className="octo-button" style={{ color: "#000000", fontWeight: 600 }} onClick={(e) => { OctoUtils.showViewMenu(e, mutator, boardTree, pageController) }}><div className="imageDropdown"></div></div>
|
<div
|
||||||
|
className="octo-button"
|
||||||
|
style={{ color: "#000000", fontWeight: 600 }}
|
||||||
|
onClick={() => this.setState({ viewMenu: true })}
|
||||||
|
>
|
||||||
|
{this.state.viewMenu &&
|
||||||
|
<ViewMenu
|
||||||
|
board={board}
|
||||||
|
onClose={() => this.setState({ viewMenu: false })}
|
||||||
|
mutator={mutator}
|
||||||
|
boardTree={boardTree}
|
||||||
|
showView={showView}
|
||||||
|
/>}
|
||||||
|
<div className="imageDropdown"></div>
|
||||||
|
</div>
|
||||||
<div className="octo-spacer"></div>
|
<div className="octo-spacer"></div>
|
||||||
<div className="octo-button" onClick={(e) => { this.propertiesClicked(e) }}>Properties</div>
|
<div className="octo-button" onClick={(e) => { this.propertiesClicked(e) }}>Properties</div>
|
||||||
<div className={ hasFilter ? "octo-button active" : "octo-button"} onClick={(e) => { this.filterClicked(e) }}>Filter</div>
|
<div className={hasFilter ? "octo-button active" : "octo-button"} onClick={(e) => { this.filterClicked(e) }}>Filter</div>
|
||||||
<div className={ hasSort ? "octo-button active" : "octo-button"} onClick={(e) => { this.sortClicked(e) }}>Sort</div>
|
<div className={hasSort ? "octo-button active" : "octo-button"} onClick={(e) => { OctoUtils.showSortMenu(e, mutator, boardTree) }}>Sort</div>
|
||||||
<div className="octo-button">Search</div>
|
{this.state.isSearching
|
||||||
|
? <Editable
|
||||||
|
ref={this.searchFieldRef}
|
||||||
|
text={boardTree.getSearchText()}
|
||||||
|
placeholderText="Search text"
|
||||||
|
style={{ color: "#000000" }}
|
||||||
|
onChanged={(text) => { this.searchChanged(text) }}
|
||||||
|
onKeyDown={(e) => { this.onSearchKeyDown(e) }}></Editable>
|
||||||
|
: <div className="octo-button" onClick={() => { this.setState({ ...this.state, isSearching: true }) }}>Search</div>
|
||||||
|
}
|
||||||
<div className="octo-button" onClick={(e) => this.optionsClicked(e)}><div className="imageOptions"></div></div>
|
<div className="octo-button" onClick={(e) => this.optionsClicked(e)}><div className="imageOptions"></div></div>
|
||||||
<div className="octo-button filled" onClick={() => { this.addCard(true) }}>New</div>
|
<div className="octo-button filled" onClick={() => { this.addCard(true) }}>New</div>
|
||||||
</div>
|
</div>
|
||||||
@ -95,7 +130,7 @@ class TableComponent extends React.Component<Props, State> {
|
|||||||
{/* Headers */}
|
{/* Headers */}
|
||||||
|
|
||||||
<div className="octo-table-header" id="mainBoardHeader">
|
<div className="octo-table-header" id="mainBoardHeader">
|
||||||
<div className="octo-table-cell" id="mainBoardHeader">
|
<div className="octo-table-cell title-cell" id="mainBoardHeader">
|
||||||
<div
|
<div
|
||||||
className="octo-label"
|
className="octo-label"
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
@ -182,11 +217,11 @@ class TableComponent extends React.Component<Props, State> {
|
|||||||
const { mutator, boardTree } = this.props
|
const { mutator, boardTree } = this.props
|
||||||
const { board } = boardTree
|
const { board } = boardTree
|
||||||
|
|
||||||
Menu.shared.options = [
|
OldMenu.shared.options = [
|
||||||
{ id: "random", name: "Random" },
|
{ id: "random", name: "Random" },
|
||||||
{ id: "remove", name: "Remove Icon" },
|
{ id: "remove", name: "Remove Icon" },
|
||||||
]
|
]
|
||||||
Menu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
OldMenu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
||||||
switch (optionId) {
|
switch (optionId) {
|
||||||
case "remove":
|
case "remove":
|
||||||
mutator.changeIcon(board, undefined, "remove icon")
|
mutator.changeIcon(board, undefined, "remove icon")
|
||||||
@ -197,7 +232,7 @@ class TableComponent extends React.Component<Props, State> {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
OldMenu.shared.showAtElement(e.target as HTMLElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async propertiesClicked(e: React.MouseEvent) {
|
private async propertiesClicked(e: React.MouseEvent) {
|
||||||
@ -205,12 +240,12 @@ class TableComponent extends React.Component<Props, State> {
|
|||||||
const { activeView } = boardTree
|
const { activeView } = boardTree
|
||||||
|
|
||||||
const selectProperties = boardTree.board.cardProperties
|
const selectProperties = boardTree.board.cardProperties
|
||||||
Menu.shared.options = selectProperties.map((o) => {
|
OldMenu.shared.options = selectProperties.map((o) => {
|
||||||
const isVisible = activeView.visiblePropertyIds.includes(o.id)
|
const isVisible = activeView.visiblePropertyIds.includes(o.id)
|
||||||
return { id: o.id, name: o.name, type: "switch", isOn: isVisible }
|
return { id: o.id, name: o.name, type: "switch", isOn: isVisible }
|
||||||
})
|
})
|
||||||
|
|
||||||
Menu.shared.onMenuToggled = async (id: string, isOn: boolean) => {
|
OldMenu.shared.onMenuToggled = async (id: string, isOn: boolean) => {
|
||||||
const property = selectProperties.find(o => o.id === id)
|
const property = selectProperties.find(o => o.id === id)
|
||||||
Utils.assertValue(property)
|
Utils.assertValue(property)
|
||||||
Utils.log(`Toggle property ${property.name} ${isOn}`)
|
Utils.log(`Toggle property ${property.name} ${isOn}`)
|
||||||
@ -223,49 +258,22 @@ class TableComponent extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
await mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
|
await mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
|
||||||
}
|
}
|
||||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
OldMenu.shared.showAtElement(e.target as HTMLElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
private filterClicked(e: React.MouseEvent) {
|
private filterClicked(e: React.MouseEvent) {
|
||||||
const { pageController } = this.props
|
this.props.showFilter(e.target as HTMLElement)
|
||||||
pageController.showFilter(e.target as HTMLElement)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sortClicked(e: React.MouseEvent) {
|
|
||||||
const { mutator, boardTree } = this.props
|
|
||||||
const { activeView } = boardTree
|
|
||||||
const { sortOptions } = activeView
|
|
||||||
const sortOption = sortOptions.length > 0 ? sortOptions[0] : undefined
|
|
||||||
|
|
||||||
const propertyTemplates = boardTree.board.cardProperties
|
|
||||||
Menu.shared.options = propertyTemplates.map((o) => { return { id: o.id, name: o.name } })
|
|
||||||
Menu.shared.onMenuClicked = async (propertyId: string) => {
|
|
||||||
let newSortOptions: ISortOption[] = []
|
|
||||||
if (sortOption && sortOption.propertyId === propertyId) {
|
|
||||||
// Already sorting by name, so reverse it
|
|
||||||
newSortOptions = [
|
|
||||||
{ propertyId, reversed: !sortOption.reversed }
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
newSortOptions = [
|
|
||||||
{ propertyId, reversed: false }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
await mutator.changeViewSortOptions(activeView, newSortOptions)
|
|
||||||
}
|
|
||||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async optionsClicked(e: React.MouseEvent) {
|
private async optionsClicked(e: React.MouseEvent) {
|
||||||
const { boardTree } = this.props
|
const { boardTree } = this.props
|
||||||
|
|
||||||
Menu.shared.options = [
|
OldMenu.shared.options = [
|
||||||
{ id: "exportCsv", name: "Export to CSV" },
|
{ id: "exportCsv", name: "Export to CSV" },
|
||||||
{ id: "exportBoardArchive", name: "Export board archive" },
|
{ id: "exportBoardArchive", name: "Export board archive" },
|
||||||
]
|
]
|
||||||
|
|
||||||
Menu.shared.onMenuClicked = async (id: string) => {
|
OldMenu.shared.onMenuClicked = async (id: string) => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "exportCsv": {
|
case "exportCsv": {
|
||||||
CsvExporter.exportTableCsv(boardTree)
|
CsvExporter.exportTableCsv(boardTree)
|
||||||
@ -277,7 +285,7 @@ class TableComponent extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
OldMenu.shared.showAtElement(e.target as HTMLElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async headerClicked(e: React.MouseEvent<HTMLDivElement>, templateId: string) {
|
private async headerClicked(e: React.MouseEvent<HTMLDivElement>, templateId: string) {
|
||||||
@ -298,8 +306,8 @@ class TableComponent extends React.Component<Props, State> {
|
|||||||
options.push({ id: "delete", name: "Delete" })
|
options.push({ id: "delete", name: "Delete" })
|
||||||
}
|
}
|
||||||
|
|
||||||
Menu.shared.options = options
|
OldMenu.shared.options = options
|
||||||
Menu.shared.onMenuClicked = async (optionId: string, type?: string) => {
|
OldMenu.shared.onMenuClicked = async (optionId: string, type?: string) => {
|
||||||
switch (optionId) {
|
switch (optionId) {
|
||||||
case "sortAscending": {
|
case "sortAscending": {
|
||||||
const newSortOptions = [
|
const newSortOptions = [
|
||||||
@ -352,13 +360,13 @@ class TableComponent extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
OldMenu.shared.showAtElement(e.target as HTMLElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
async showCard(card: IBlock) {
|
async showCard(card: IBlock) {
|
||||||
console.log(`showCard: ${card.title}`)
|
console.log(`showCard: ${card.title}`)
|
||||||
|
|
||||||
await this.props.pageController.showCard(card)
|
await this.props.showCard(card)
|
||||||
}
|
}
|
||||||
|
|
||||||
focusOnCardTitle(cardId: string) {
|
focusOnCardTitle(cardId: string) {
|
||||||
@ -401,6 +409,19 @@ class TableComponent extends React.Component<Props, State> {
|
|||||||
const destIndex = template ? board.cardProperties.indexOf(template) : 0
|
const destIndex = template ? board.cardProperties.indexOf(template) : 0
|
||||||
await mutator.changePropertyTemplateOrder(board, draggedHeaderTemplate, destIndex)
|
await mutator.changePropertyTemplateOrder(board, draggedHeaderTemplate, destIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSearchKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.keyCode === 27) { // ESC: Clear search
|
||||||
|
this.searchFieldRef.current.text = ""
|
||||||
|
this.setState({ ...this.state, isSearching: false })
|
||||||
|
this.props.setSearchText(undefined)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchChanged(text?: string) {
|
||||||
|
this.props.setSearchText(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { TableComponent }
|
export { TableComponent }
|
||||||
|
@ -36,7 +36,7 @@ class TableRow extends React.Component<Props, State> {
|
|||||||
|
|
||||||
{/* Name / title */}
|
{/* Name / title */}
|
||||||
|
|
||||||
<div className="octo-table-cell" id="mainBoardHeader" onMouseOver={() => { openButonRef.current.style.display = null }} onMouseLeave={() => { openButonRef.current.style.display = "none" }}>
|
<div className="octo-table-cell title-cell" id="mainBoardHeader" onMouseOver={() => { openButonRef.current.style.display = null }} onMouseLeave={() => { openButonRef.current.style.display = "none" }}>
|
||||||
<div className="octo-icontitle">
|
<div className="octo-icontitle">
|
||||||
<div className="octo-icon">{card.icon}</div>
|
<div className="octo-icon">{card.icon}</div>
|
||||||
<Editable
|
<Editable
|
||||||
|
84
src/client/components/viewMenu.tsx
Normal file
84
src/client/components/viewMenu.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Board } from "../board"
|
||||||
|
import { BoardTree } from "../boardTree"
|
||||||
|
import { BoardView } from "../boardView"
|
||||||
|
import { Mutator } from "../mutator"
|
||||||
|
import { Utils } from "../utils"
|
||||||
|
import Menu from "../widgets/menu"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mutator: Mutator,
|
||||||
|
boardTree?: BoardTree
|
||||||
|
board: Board,
|
||||||
|
showView: (id: string) => void
|
||||||
|
onClose: () => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ViewMenu extends React.Component<Props> {
|
||||||
|
handleDeleteView = async (id: string) => {
|
||||||
|
const { board, boardTree, mutator, showView } = this.props
|
||||||
|
Utils.log(`deleteView`)
|
||||||
|
const view = boardTree.activeView
|
||||||
|
const nextView = boardTree.views.find(o => o !== view)
|
||||||
|
await mutator.deleteBlock(view, "delete view")
|
||||||
|
showView(nextView.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleViewClick = (id: string) => {
|
||||||
|
const { boardTree, showView } = this.props
|
||||||
|
Utils.log(`view ` + id)
|
||||||
|
const view = boardTree.views.find(o => o.id === id)
|
||||||
|
showView(view.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAddViewBoard = async (id: string) => {
|
||||||
|
const { board, boardTree, mutator, showView } = this.props
|
||||||
|
Utils.log(`addview-board`)
|
||||||
|
const view = new BoardView()
|
||||||
|
view.title = "Board View"
|
||||||
|
view.viewType = "board"
|
||||||
|
view.parentId = board.id
|
||||||
|
|
||||||
|
const oldViewId = boardTree.activeView.id
|
||||||
|
|
||||||
|
await mutator.insertBlock(
|
||||||
|
view,
|
||||||
|
"add view",
|
||||||
|
async () => { showView(view.id) },
|
||||||
|
async () => { showView(oldViewId) })
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAddViewTable = async (id: string) => {
|
||||||
|
const { board, boardTree, mutator, showView } = this.props
|
||||||
|
|
||||||
|
Utils.log(`addview-table`)
|
||||||
|
const view = new BoardView()
|
||||||
|
view.title = "Table View"
|
||||||
|
view.viewType = "table"
|
||||||
|
view.parentId = board.id
|
||||||
|
view.visiblePropertyIds = board.cardProperties.map(o => o.id)
|
||||||
|
|
||||||
|
const oldViewId = boardTree.activeView.id
|
||||||
|
|
||||||
|
await mutator.insertBlock(
|
||||||
|
view,
|
||||||
|
"add view",
|
||||||
|
async () => { showView(view.id) },
|
||||||
|
async () => { showView(oldViewId) })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { onClose, boardTree } = this.props
|
||||||
|
return (
|
||||||
|
<Menu onClose={onClose}>
|
||||||
|
{boardTree.views.map((view) => (<Menu.Text key={view.id} id={view.id} name={view.title} onClick={this.handleViewClick} />))}
|
||||||
|
<Menu.Separator />
|
||||||
|
{boardTree.views.length > 1 && <Menu.Text id="__deleteView" name="Delete View" onClick={this.handleDeleteView} />}
|
||||||
|
<Menu.SubMenu id="__addView" name="Add View">
|
||||||
|
<Menu.Text id='board' name='Board' onClick={this.handleAddViewBoard} />
|
||||||
|
<Menu.Text id='table' name='Table' onClick={this.handleAddViewTable} />
|
||||||
|
</Menu.SubMenu>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
62
src/client/components/workspaceComponent.tsx
Normal file
62
src/client/components/workspaceComponent.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { BoardTree } from "../boardTree"
|
||||||
|
import { Mutator } from "../mutator"
|
||||||
|
import { IBlock } from "../octoTypes"
|
||||||
|
import { Utils } from "../utils"
|
||||||
|
import { WorkspaceTree } from "../workspaceTree"
|
||||||
|
import { BoardComponent } from "./boardComponent"
|
||||||
|
import { Sidebar } from "./sidebar"
|
||||||
|
import { TableComponent } from "./tableComponent"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mutator: Mutator,
|
||||||
|
workspaceTree: WorkspaceTree
|
||||||
|
boardTree?: BoardTree
|
||||||
|
showBoard: (id: string) => void
|
||||||
|
showView: (id: string) => void
|
||||||
|
showCard: (card: IBlock) => void
|
||||||
|
showFilter: (el: HTMLElement) => void
|
||||||
|
setSearchText: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
class WorkspaceComponent extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
const { mutator, boardTree, workspaceTree, showBoard } = this.props
|
||||||
|
|
||||||
|
Utils.assert(workspaceTree)
|
||||||
|
const element =
|
||||||
|
<div className="octo-workspace">
|
||||||
|
<Sidebar mutator={mutator} showBoard={showBoard} workspaceTree={workspaceTree} boardTree={boardTree}></Sidebar>
|
||||||
|
{this.mainComponent()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
private mainComponent() {
|
||||||
|
const { mutator, boardTree, showCard, showFilter, setSearchText, showView } = this.props
|
||||||
|
const { activeView } = boardTree || {}
|
||||||
|
|
||||||
|
if (!activeView) {
|
||||||
|
return <div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (activeView?.viewType) {
|
||||||
|
case "board": {
|
||||||
|
return <BoardComponent mutator={mutator} boardTree={boardTree} showCard={showCard} showFilter={showFilter} setSearchText={setSearchText} showView={showView} />
|
||||||
|
}
|
||||||
|
|
||||||
|
case "table": {
|
||||||
|
return <TableComponent mutator={mutator} boardTree={boardTree} showCard={showCard} showFilter={showFilter} setSearchText={setSearchText} showView={showView} />
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
Utils.assertFailure(`render() Unhandled viewType: ${activeView.viewType}`)
|
||||||
|
return <div></div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WorkspaceComponent }
|
@ -49,10 +49,10 @@ class CsvExporter {
|
|||||||
cards.forEach(card => {
|
cards.forEach(card => {
|
||||||
const row: string[] = []
|
const row: string[] = []
|
||||||
visibleProperties.forEach(template => {
|
visibleProperties.forEach(template => {
|
||||||
const property = card.properties.find(o => o.id === template.id)
|
const propertyValue = card.properties[template.id]
|
||||||
const displayValue = OctoUtils.propertyDisplayValue(card, property, template) || ""
|
const displayValue = OctoUtils.propertyDisplayValue(card, propertyValue, template) || ""
|
||||||
if (template.type === "number") {
|
if (template.type === "number") {
|
||||||
const numericValue = property?.value ? Number(property?.value).toString() : undefined
|
const numericValue = propertyValue ? Number(propertyValue).toString() : undefined
|
||||||
row.push(numericValue)
|
row.push(numericValue)
|
||||||
} else {
|
} else {
|
||||||
// Export as string
|
// Export as string
|
||||||
|
6
src/client/main.tsx
Normal file
6
src/client/main.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
|
||||||
|
import App from './app';
|
||||||
|
|
||||||
|
ReactDOM.render(<App />, document.getElementById('octo-tasks-app'));
|
@ -4,6 +4,7 @@ type MenuOption = {
|
|||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
isOn?: boolean,
|
isOn?: boolean,
|
||||||
|
icon?: "checked" | "sortUp" | "sortDown" | undefined,
|
||||||
type?: "separator" | "color" | "submenu" | "switch" | undefined
|
type?: "separator" | "color" | "submenu" | "switch" | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,6 +55,20 @@ class Menu {
|
|||||||
this.showSubMenu(rect.right - bodyRect.left, rect.top - bodyRect.top, option.id)
|
this.showSubMenu(rect.right - bodyRect.left, rect.top - bodyRect.top, option.id)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
|
if (option.icon) {
|
||||||
|
let iconName: string
|
||||||
|
switch (option.icon) {
|
||||||
|
case "checked": { iconName = "imageMenuCheck"; break }
|
||||||
|
case "sortUp": { iconName = "imageMenuSortUp"; break }
|
||||||
|
case "sortDown": { iconName = "imageMenuSortDown"; break }
|
||||||
|
default: { Utils.assertFailure(`Unsupported menu icon: ${option.icon}`) }
|
||||||
|
}
|
||||||
|
if (iconName) {
|
||||||
|
optionElement.appendChild(Utils.htmlToElement(`<div class="${iconName}" style="float: right;"></div>`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
optionElement.onmouseenter = () => {
|
optionElement.onmouseenter = () => {
|
||||||
this.hideSubMenu()
|
this.hideSubMenu()
|
||||||
}
|
}
|
||||||
|
@ -50,7 +50,7 @@ class Mutator {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteBlock(block: IBlock, description?: string) {
|
async deleteBlock(block: IBlock, description?: string, beforeRedo?: () => Promise<void>, afterUndo?: () => Promise<void>) {
|
||||||
const { octo, undoManager } = this
|
const { octo, undoManager } = this
|
||||||
|
|
||||||
if (!description) {
|
if (!description) {
|
||||||
@ -59,10 +59,12 @@ class Mutator {
|
|||||||
|
|
||||||
await undoManager.perform(
|
await undoManager.perform(
|
||||||
async () => {
|
async () => {
|
||||||
|
await beforeRedo?.()
|
||||||
await octo.deleteBlock(block.id)
|
await octo.deleteBlock(block.id)
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
await octo.insertBlock(block)
|
await octo.insertBlock(block)
|
||||||
|
await afterUndo?.()
|
||||||
},
|
},
|
||||||
description
|
description
|
||||||
)
|
)
|
||||||
@ -243,9 +245,9 @@ class Mutator {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
cards.forEach(card => {
|
cards.forEach(card => {
|
||||||
if (card.properties.findIndex(o => o.id === propertyId) !== -1) {
|
if (card.properties[propertyId]) {
|
||||||
oldBlocks.push(new Block(card))
|
oldBlocks.push(new Block(card))
|
||||||
card.properties = card.properties.filter(o => o.id !== propertyId)
|
delete card.properties[propertyId]
|
||||||
changedBlocks.push(card)
|
changedBlocks.push(card)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -364,13 +366,12 @@ class Mutator {
|
|||||||
|
|
||||||
// Change the value on all cards that have this property too
|
// Change the value on all cards that have this property too
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
card.properties.forEach(property => {
|
const propertyValue = card.properties[propertyTemplate.id]
|
||||||
if (property.id === propertyTemplate.id && property.value === oldValue) {
|
if (propertyValue && propertyValue === oldValue) {
|
||||||
oldBlocks.push(new Block(card))
|
oldBlocks.push(new Block(card))
|
||||||
property.value = value
|
card.properties[propertyTemplate.id] = value
|
||||||
changedBlocks.push(card)
|
changedBlocks.push(card)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await undoManager.perform(
|
await undoManager.perform(
|
||||||
@ -407,14 +408,14 @@ class Mutator {
|
|||||||
async changePropertyValue(block: IBlock, propertyId: string, value?: string, description: string = "change property") {
|
async changePropertyValue(block: IBlock, propertyId: string, value?: string, description: string = "change property") {
|
||||||
const { octo, undoManager } = this
|
const { octo, undoManager } = this
|
||||||
|
|
||||||
const oldValue = Block.getPropertyValue(block, propertyId)
|
const oldValue = block.properties[propertyId]
|
||||||
await undoManager.perform(
|
await undoManager.perform(
|
||||||
async () => {
|
async () => {
|
||||||
Block.setProperty(block, propertyId, value)
|
block.properties[propertyId] = value
|
||||||
await octo.updateBlock(block)
|
await octo.updateBlock(block)
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
Block.setProperty(block, propertyId, oldValue)
|
block.properties[propertyId] = oldValue
|
||||||
await octo.updateBlock(block)
|
await octo.updateBlock(block)
|
||||||
},
|
},
|
||||||
description
|
description
|
||||||
|
@ -64,9 +64,18 @@ class OctoClient {
|
|||||||
|
|
||||||
fixBlocks(blocks: IBlock[]) {
|
fixBlocks(blocks: IBlock[]) {
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
if (!block.properties) { block.properties = [] }
|
if (!block.properties) { block.properties = {} }
|
||||||
|
|
||||||
block.properties = block.properties.filter(property => property && property.id)
|
if (Array.isArray(block.properties)) {
|
||||||
|
// PORT from old schema
|
||||||
|
const properties: Record<string, string> = {}
|
||||||
|
for (const property of block.properties) {
|
||||||
|
if (property.id) {
|
||||||
|
properties[property.id] = property.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
block.properties = properties
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,3 @@
|
|||||||
// A property on a bock
|
|
||||||
interface IProperty {
|
|
||||||
id: string
|
|
||||||
value?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// A block is the fundamental data type
|
// A block is the fundamental data type
|
||||||
interface IBlock {
|
interface IBlock {
|
||||||
id: string
|
id: string
|
||||||
@ -11,10 +5,10 @@ interface IBlock {
|
|||||||
|
|
||||||
type: string
|
type: string
|
||||||
title?: string
|
title?: string
|
||||||
url?: string
|
url?: string // TODO: Move to properties (_url)
|
||||||
icon?: string
|
icon?: string
|
||||||
order: number
|
order: number
|
||||||
properties: IProperty[]
|
properties: Record<string, string>
|
||||||
|
|
||||||
createAt: number
|
createAt: number
|
||||||
updateAt: number
|
updateAt: number
|
||||||
@ -24,8 +18,10 @@ interface IBlock {
|
|||||||
// These are methods exposed by the top-level page to components
|
// These are methods exposed by the top-level page to components
|
||||||
interface IPageController {
|
interface IPageController {
|
||||||
showCard(card: IBlock): Promise<void>
|
showCard(card: IBlock): Promise<void>
|
||||||
|
showBoard(boardId: string): void
|
||||||
showView(viewId: string): void
|
showView(viewId: string): void
|
||||||
showFilter(anchorElement?: HTMLElement): void
|
showFilter(anchorElement?: HTMLElement): void
|
||||||
|
setSearchText(text?: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export { IProperty, IBlock, IPageController }
|
export { IBlock, IPageController }
|
||||||
|
@ -1,84 +1,15 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { IPropertyTemplate } from "./board"
|
import { IPropertyTemplate } from "./board"
|
||||||
import { BoardTree } from "./boardTree"
|
import { BoardTree } from "./boardTree"
|
||||||
import { BoardView } from "./boardView"
|
import { BoardView, ISortOption } from "./boardView"
|
||||||
import { Editable } from "./components/editable"
|
import { Editable } from "./components/editable"
|
||||||
import { Menu, MenuOption } from "./menu"
|
import { Menu, MenuOption } from "./menu"
|
||||||
import { Mutator } from "./mutator"
|
import { Mutator } from "./mutator"
|
||||||
import { IBlock, IPageController, IProperty } from "./octoTypes"
|
import { IBlock } from "./octoTypes"
|
||||||
import { Utils } from "./utils"
|
import { Utils } from "./utils"
|
||||||
|
|
||||||
class OctoUtils {
|
class OctoUtils {
|
||||||
static async showViewMenu(e: React.MouseEvent, mutator: Mutator, boardTree: BoardTree, pageController: IPageController) {
|
static propertyDisplayValue(block: IBlock, propertyValue: string | undefined, propertyTemplate: IPropertyTemplate) {
|
||||||
const { board } = boardTree
|
|
||||||
|
|
||||||
const options: MenuOption[] = boardTree.views.map(view => ({ id: view.id, name: view.title || "Untitled View" }))
|
|
||||||
options.push({ id: "", name: "", type: "separator" })
|
|
||||||
if (boardTree.views.length > 1) {
|
|
||||||
options.push({ id: "__deleteView", name: "Delete View" })
|
|
||||||
}
|
|
||||||
options.push({ id: "__addview", name: "Add View", type: "submenu" })
|
|
||||||
|
|
||||||
const addViewMenuOptions = [
|
|
||||||
{ id: "board", name: "Board" },
|
|
||||||
{ id: "table", name: "Table" }
|
|
||||||
]
|
|
||||||
Menu.shared.subMenuOptions.set("__addview", addViewMenuOptions)
|
|
||||||
|
|
||||||
Menu.shared.options = options
|
|
||||||
Menu.shared.onMenuClicked = async (optionId: string, type?: string) => {
|
|
||||||
switch (optionId) {
|
|
||||||
case "__deleteView": {
|
|
||||||
Utils.log(`deleteView`)
|
|
||||||
const view = boardTree.activeView
|
|
||||||
const nextView = boardTree.views.find(o => o !== view)
|
|
||||||
await mutator.deleteBlock(view, "delete view")
|
|
||||||
pageController.showView(nextView.id)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "__addview-board": {
|
|
||||||
Utils.log(`addview-board`)
|
|
||||||
const view = new BoardView()
|
|
||||||
view.title = "Board View"
|
|
||||||
view.viewType = "board"
|
|
||||||
view.parentId = board.id
|
|
||||||
|
|
||||||
const oldViewId = boardTree.activeView.id
|
|
||||||
|
|
||||||
await mutator.insertBlock(
|
|
||||||
view,
|
|
||||||
"add view",
|
|
||||||
async () => { pageController.showView(view.id) },
|
|
||||||
async () => { pageController.showView(oldViewId) })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "__addview-table": {
|
|
||||||
Utils.log(`addview-table`)
|
|
||||||
const view = new BoardView()
|
|
||||||
view.title = "Table View"
|
|
||||||
view.viewType = "table"
|
|
||||||
view.parentId = board.id
|
|
||||||
view.visiblePropertyIds = board.cardProperties.map(o => o.id)
|
|
||||||
|
|
||||||
const oldViewId = boardTree.activeView.id
|
|
||||||
|
|
||||||
await mutator.insertBlock(
|
|
||||||
view,
|
|
||||||
"add view",
|
|
||||||
async () => { pageController.showView(view.id) },
|
|
||||||
async () => { pageController.showView(oldViewId) })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
const view = boardTree.views.find(o => o.id === optionId)
|
|
||||||
pageController.showView(view.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
|
||||||
}
|
|
||||||
|
|
||||||
static propertyDisplayValue(block: IBlock, property: IProperty, propertyTemplate: IPropertyTemplate) {
|
|
||||||
let displayValue: string
|
let displayValue: string
|
||||||
switch (propertyTemplate.type) {
|
switch (propertyTemplate.type) {
|
||||||
case "createdTime":
|
case "createdTime":
|
||||||
@ -88,7 +19,7 @@ class OctoUtils {
|
|||||||
displayValue = Utils.displayDateTime(new Date(block.updateAt))
|
displayValue = Utils.displayDateTime(new Date(block.updateAt))
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
displayValue = property ? property.value : undefined
|
displayValue = propertyValue
|
||||||
}
|
}
|
||||||
|
|
||||||
return displayValue
|
return displayValue
|
||||||
@ -103,13 +34,13 @@ class OctoUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static propertyValueElement(mutator: Mutator | undefined, card: IBlock, propertyTemplate: IPropertyTemplate, emptyDisplayValue: string = "Empty"): JSX.Element {
|
private static propertyValueElement(mutator: Mutator | undefined, card: IBlock, propertyTemplate: IPropertyTemplate, emptyDisplayValue: string = "Empty"): JSX.Element {
|
||||||
const property = card.properties.find(o => o.id === propertyTemplate.id)
|
const propertyValue = card.properties[propertyTemplate.id]
|
||||||
const displayValue = OctoUtils.propertyDisplayValue(card, property, propertyTemplate)
|
const displayValue = OctoUtils.propertyDisplayValue(card, propertyValue, propertyTemplate)
|
||||||
const finalDisplayValue = displayValue || emptyDisplayValue
|
const finalDisplayValue = displayValue || emptyDisplayValue
|
||||||
|
|
||||||
let propertyColorCssClassName: string
|
let propertyColorCssClassName: string
|
||||||
if (property && propertyTemplate.type === "select") {
|
if (propertyValue && propertyTemplate.type === "select") {
|
||||||
const cardPropertyValue = propertyTemplate.options.find(o => o.value === property.value)
|
const cardPropertyValue = propertyTemplate.options.find(o => o.value === propertyValue)
|
||||||
if (cardPropertyValue) {
|
if (cardPropertyValue) {
|
||||||
propertyColorCssClassName = cardPropertyValue.color
|
propertyColorCssClassName = cardPropertyValue.color
|
||||||
}
|
}
|
||||||
@ -143,7 +74,7 @@ class OctoUtils {
|
|||||||
showMenu(e.target as HTMLElement)
|
showMenu(e.target as HTMLElement)
|
||||||
}
|
}
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onFocus={mutator ? () => { Menu.shared.hide() } : undefined }
|
onFocus={mutator ? () => { Menu.shared.hide() } : undefined}
|
||||||
>
|
>
|
||||||
{finalDisplayValue}
|
{finalDisplayValue}
|
||||||
</div>
|
</div>
|
||||||
@ -176,7 +107,7 @@ class OctoUtils {
|
|||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
return block.order / 2
|
return block.order / 2
|
||||||
}
|
}
|
||||||
const previousBlock = blocks[index-1]
|
const previousBlock = blocks[index - 1]
|
||||||
return (block.order + previousBlock.order) / 2
|
return (block.order + previousBlock.order) / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,9 +116,40 @@ class OctoUtils {
|
|||||||
if (index === blocks.length - 1) {
|
if (index === blocks.length - 1) {
|
||||||
return block.order + 1000
|
return block.order + 1000
|
||||||
}
|
}
|
||||||
const nextBlock = blocks[index+1]
|
const nextBlock = blocks[index + 1]
|
||||||
return (block.order + nextBlock.order) / 2
|
return (block.order + nextBlock.order) / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static showSortMenu(e: React.MouseEvent, mutator: Mutator, boardTree: BoardTree) {
|
||||||
|
const { activeView } = boardTree
|
||||||
|
const { sortOptions } = activeView
|
||||||
|
const sortOption = sortOptions.length > 0 ? sortOptions[0] : undefined
|
||||||
|
|
||||||
|
const propertyTemplates = boardTree.board.cardProperties
|
||||||
|
Menu.shared.options = propertyTemplates.map((o) => {
|
||||||
|
return {
|
||||||
|
id: o.id,
|
||||||
|
name: o.name,
|
||||||
|
icon: (sortOption?.propertyId === o.id) ? sortOption.reversed ? "sortUp" : "sortDown" : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Menu.shared.onMenuClicked = async (propertyId: string) => {
|
||||||
|
let newSortOptions: ISortOption[] = []
|
||||||
|
if (sortOption && sortOption.propertyId === propertyId) {
|
||||||
|
// Already sorting by name, so reverse it
|
||||||
|
newSortOptions = [
|
||||||
|
{ propertyId, reversed: !sortOption.reversed }
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
newSortOptions = [
|
||||||
|
{ propertyId, reversed: false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
await mutator.changeViewSortOptions(activeView, newSortOptions)
|
||||||
|
}
|
||||||
|
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { OctoUtils }
|
export { OctoUtils }
|
||||||
|
256
src/client/pages/boardPage.tsx
Normal file
256
src/client/pages/boardPage.tsx
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import React from "react"
|
||||||
|
import ReactDOM from "react-dom"
|
||||||
|
import { BoardTree } from "../boardTree"
|
||||||
|
import { BoardView } from "../boardView"
|
||||||
|
import { CardTree } from "../cardTree"
|
||||||
|
import { CardDialog } from "../components/cardDialog"
|
||||||
|
import { FilterComponent } from "../components/filterComponent"
|
||||||
|
import { WorkspaceComponent } from "../components/workspaceComponent"
|
||||||
|
import { FlashMessage } from "../flashMessage"
|
||||||
|
import { Mutator } from "../mutator"
|
||||||
|
import { OctoClient } from "../octoClient"
|
||||||
|
import { OctoListener } from "../octoListener"
|
||||||
|
import { IBlock } from "../octoTypes"
|
||||||
|
import { UndoManager } from "../undomanager"
|
||||||
|
import { Utils } from "../utils"
|
||||||
|
import { WorkspaceTree } from "../workspaceTree"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
boardId: string
|
||||||
|
viewId: string
|
||||||
|
workspaceTree: WorkspaceTree
|
||||||
|
boardTree?: BoardTree
|
||||||
|
shownCardTree?: CardTree
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class BoardPage extends React.Component<Props, State> {
|
||||||
|
view: BoardView
|
||||||
|
|
||||||
|
updateTitleTimeout: number
|
||||||
|
updatePropertyLabelTimeout: number
|
||||||
|
|
||||||
|
private filterAnchorElement?: HTMLElement
|
||||||
|
private octo = new OctoClient()
|
||||||
|
private boardListener = new OctoListener()
|
||||||
|
private cardListener = new OctoListener()
|
||||||
|
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
const queryString = new URLSearchParams(window.location.search)
|
||||||
|
const boardId = queryString.get("id")
|
||||||
|
const viewId = queryString.get("v")
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
boardId,
|
||||||
|
viewId,
|
||||||
|
workspaceTree: new WorkspaceTree(this.octo),
|
||||||
|
}
|
||||||
|
|
||||||
|
Utils.log(`BoardPage. boardId: ${boardId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||||
|
Utils.log(`componentDidUpdate`)
|
||||||
|
const board = this.state.boardTree?.board
|
||||||
|
const prevBoard = prevState.boardTree?.board
|
||||||
|
|
||||||
|
const activeView = this.state.boardTree?.activeView
|
||||||
|
const prevActiveView = prevState.boardTree?.activeView
|
||||||
|
|
||||||
|
if (board?.icon !== prevBoard?.icon) {
|
||||||
|
Utils.setFavicon(board?.icon)
|
||||||
|
}
|
||||||
|
if (board?.title !== prevBoard?.title || activeView?.title !== prevActiveView?.title) {
|
||||||
|
document.title = `OCTO - ${board?.title} | ${activeView?.title}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undoRedoHandler = async (e: KeyboardEvent) => {
|
||||||
|
if (e.target !== document.body) { return }
|
||||||
|
|
||||||
|
if (e.keyCode === 90 && !e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) { // Cmd+Z
|
||||||
|
Utils.log(`Undo`)
|
||||||
|
const description = UndoManager.shared.undoDescription
|
||||||
|
await UndoManager.shared.undo()
|
||||||
|
if (description) {
|
||||||
|
FlashMessage.show(`Undo ${description}`)
|
||||||
|
} else {
|
||||||
|
FlashMessage.show(`Undo`)
|
||||||
|
}
|
||||||
|
} else if (e.keyCode === 90 && e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) { // Shift+Cmd+Z
|
||||||
|
Utils.log(`Redo`)
|
||||||
|
const description = UndoManager.shared.redoDescription
|
||||||
|
await UndoManager.shared.redo()
|
||||||
|
if (description) {
|
||||||
|
FlashMessage.show(`Redo ${description}`)
|
||||||
|
} else {
|
||||||
|
FlashMessage.show(`Redo`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
document.addEventListener("keydown", this.undoRedoHandler)
|
||||||
|
if (this.state.boardId) {
|
||||||
|
this.attachToBoard(this.state.boardId, this.state.viewId)
|
||||||
|
} else {
|
||||||
|
this.sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
document.removeEventListener("keydown", this.undoRedoHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { workspaceTree, shownCardTree } = this.state
|
||||||
|
const { board, activeView } = this.state.boardTree || {}
|
||||||
|
const mutator = new Mutator(this.octo)
|
||||||
|
|
||||||
|
// TODO Move all this into the root portal component when that is merged
|
||||||
|
if (this.state.boardTree && this.state.boardTree.board && shownCardTree) {
|
||||||
|
ReactDOM.render(
|
||||||
|
<CardDialog mutator={mutator} boardTree={this.state.boardTree} cardTree={shownCardTree} onClose={() => { this.showCard(undefined) }}></CardDialog>,
|
||||||
|
Utils.getElementById("overlay")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const overlay = document.getElementById("overlay")
|
||||||
|
if (overlay) {
|
||||||
|
ReactDOM.render(
|
||||||
|
<div />,
|
||||||
|
overlay
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.filterAnchorElement) {
|
||||||
|
const element = this.filterAnchorElement
|
||||||
|
const bodyRect = document.body.getBoundingClientRect()
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
// Show at bottom-left of element
|
||||||
|
const maxX = bodyRect.right - 420 - 100
|
||||||
|
const pageX = Math.min(maxX, rect.left - bodyRect.left)
|
||||||
|
const pageY = rect.bottom - bodyRect.top
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<FilterComponent
|
||||||
|
mutator={mutator}
|
||||||
|
boardTree={this.state.boardTree}
|
||||||
|
pageX={pageX}
|
||||||
|
pageY={pageY}
|
||||||
|
onClose={() => { this.showFilter(undefined) }}
|
||||||
|
>
|
||||||
|
</FilterComponent>,
|
||||||
|
Utils.getElementById("modal")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const modal = document.getElementById("modal")
|
||||||
|
if (modal) {
|
||||||
|
ReactDOM.render(<div />, modal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Utils.log(`BoardPage.render ${this.state.boardTree?.board?.title}`)
|
||||||
|
return (
|
||||||
|
<div className='BoardPage'>
|
||||||
|
<WorkspaceComponent
|
||||||
|
mutator={mutator}
|
||||||
|
workspaceTree={workspaceTree}
|
||||||
|
boardTree={this.state.boardTree}
|
||||||
|
showView={(id) => { this.showView(id) }}
|
||||||
|
showCard={(card) => { this.showCard(card) }}
|
||||||
|
showBoard={(id) => { this.showBoard(id) }}
|
||||||
|
showFilter={(el) => { this.showFilter(el) }}
|
||||||
|
setSearchText={(text) => { this.setSearchText(text) }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async attachToBoard(boardId: string, viewId?: string) {
|
||||||
|
Utils.log(`attachToBoard: ${boardId}`)
|
||||||
|
|
||||||
|
this.boardListener.open(boardId, (blockId: string) => {
|
||||||
|
console.log(`octoListener.onChanged: ${blockId}`)
|
||||||
|
this.sync(boardId)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.sync(boardId, viewId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sync(boardId: string = this.state.boardId, viewId: string | undefined = this.state.viewId) {
|
||||||
|
const { workspaceTree } = this.state
|
||||||
|
Utils.log(`sync start: ${boardId}`)
|
||||||
|
|
||||||
|
await workspaceTree.sync()
|
||||||
|
|
||||||
|
if (boardId) {
|
||||||
|
const boardTree = new BoardTree(this.octo, boardId)
|
||||||
|
await boardTree.sync()
|
||||||
|
|
||||||
|
// Default to first view
|
||||||
|
if (!viewId) {
|
||||||
|
viewId = boardTree.views[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
boardTree.setActiveView(viewId)
|
||||||
|
// TODO: Handle error (viewId not found)
|
||||||
|
this.setState({
|
||||||
|
...this.state,
|
||||||
|
boardTree,
|
||||||
|
viewId: boardTree.activeView.id
|
||||||
|
})
|
||||||
|
Utils.log(`sync complete: ${boardTree.board.id} (${boardTree.board.title})`)
|
||||||
|
} else {
|
||||||
|
this.forceUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPageController
|
||||||
|
|
||||||
|
async showCard(card: IBlock) {
|
||||||
|
this.cardListener.close()
|
||||||
|
|
||||||
|
if (card) {
|
||||||
|
const cardTree = new CardTree(this.octo, card.id)
|
||||||
|
await cardTree.sync()
|
||||||
|
this.setState({...this.state, shownCardTree: cardTree})
|
||||||
|
|
||||||
|
this.cardListener = new OctoListener()
|
||||||
|
this.cardListener.open(card.id, async () => {
|
||||||
|
await cardTree.sync()
|
||||||
|
this.forceUpdate()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.setState({...this.state, shownCardTree: undefined})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showBoard(boardId: string) {
|
||||||
|
const { boardTree } = this.state
|
||||||
|
|
||||||
|
if (boardTree?.board?.id === boardId) { return }
|
||||||
|
|
||||||
|
const newUrl = window.location.protocol + "//" + window.location.host + window.location.pathname + `?id=${encodeURIComponent(boardId)}`
|
||||||
|
window.history.pushState({ path: newUrl }, "", newUrl)
|
||||||
|
|
||||||
|
this.attachToBoard(boardId)
|
||||||
|
}
|
||||||
|
|
||||||
|
showView(viewId: string) {
|
||||||
|
this.state.boardTree.setActiveView(viewId)
|
||||||
|
this.setState({ viewId, boardTree: this.state.boardTree })
|
||||||
|
const newUrl = window.location.protocol + "//" + window.location.host + window.location.pathname + `?id=${encodeURIComponent(this.state.boardId)}&v=${encodeURIComponent(viewId)}`
|
||||||
|
window.history.pushState({ path: newUrl }, "", newUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
showFilter(ahchorElement?: HTMLElement) {
|
||||||
|
this.filterAnchorElement = ahchorElement
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchText(text?: string) {
|
||||||
|
this.state.boardTree?.setSearchText(text)
|
||||||
|
}
|
||||||
|
}
|
76
src/client/pages/homePage.tsx
Normal file
76
src/client/pages/homePage.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { IBlock } from "../octoTypes"
|
||||||
|
import { Archiver } from "../archiver"
|
||||||
|
import { Board } from "../board"
|
||||||
|
import { Mutator } from "../mutator"
|
||||||
|
import { OctoClient } from "../octoClient"
|
||||||
|
import { UndoManager } from "../undomanager"
|
||||||
|
import { Utils } from "../utils"
|
||||||
|
import Button from '../components/button';
|
||||||
|
|
||||||
|
type Props = {};
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
boards: IBlock[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class HomePage extends React.Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
boards: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.loadBoards();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBoards = async () => {
|
||||||
|
const octo = new OctoClient()
|
||||||
|
const boards = await octo.getBlocks(null, "board")
|
||||||
|
this.setState({boards});
|
||||||
|
}
|
||||||
|
|
||||||
|
importClicked = async () => {
|
||||||
|
const octo = new OctoClient()
|
||||||
|
const mutator = new Mutator(octo, UndoManager.shared)
|
||||||
|
Archiver.importFullArchive(mutator, () => {
|
||||||
|
this.loadBoards()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
exportClicked = async () => {
|
||||||
|
const octo = new OctoClient()
|
||||||
|
const mutator = new Mutator(octo, UndoManager.shared)
|
||||||
|
Archiver.exportFullArchive(mutator)
|
||||||
|
}
|
||||||
|
|
||||||
|
addClicked = async () => {
|
||||||
|
const octo = new OctoClient()
|
||||||
|
const board = new Board()
|
||||||
|
await octo.insertBlock(board)
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button onClick={this.addClicked}>+ Add Board</Button>
|
||||||
|
<br />
|
||||||
|
<Button onClick={this.addClicked}>Import Archive</Button>
|
||||||
|
<br />
|
||||||
|
<Button onClick={this.addClicked}>Export Archive</Button>
|
||||||
|
{this.state.boards.map((board) => (
|
||||||
|
<p>
|
||||||
|
<a href={`/board/${board.id}`}>
|
||||||
|
{board.icon && <span>{board.icon}</span>}
|
||||||
|
<span>{board.title}</span>
|
||||||
|
<span>{Utils.displayDate(new Date(board.updateAt))}</span>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
37
src/client/pages/loginPage.tsx
Normal file
37
src/client/pages/loginPage.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
type Props = {}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class LoginPage extends React.Component<Props, State> {
|
||||||
|
state = {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLogin = () => {
|
||||||
|
console.log("Logging in");
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<div className='LoginPage'>
|
||||||
|
<label htmlFor='login-username'>Username</label>
|
||||||
|
<input
|
||||||
|
id='login-username'
|
||||||
|
value={this.state.username}
|
||||||
|
onChange={(e) => this.setState({username: e.target.value})}
|
||||||
|
/>
|
||||||
|
<label htmlFor='login-username'>Password</label>
|
||||||
|
<input
|
||||||
|
id='login-password'
|
||||||
|
/>
|
||||||
|
<button onClick={this.handleLogin}>Login</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
159
src/client/widgets/menu.tsx
Normal file
159
src/client/widgets/menu.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type MenuOptionProps = {
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
onClick?: (id: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeparatorOption() {
|
||||||
|
return (<div className="MenuOption MenuSeparator menu-separator"></div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubMenuOptionProps = MenuOptionProps & {
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubMenuState = {
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SubMenuOption extends React.Component<SubMenuOptionProps, SubMenuState> {
|
||||||
|
state = {
|
||||||
|
isOpen: false
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseEnter = () => {
|
||||||
|
this.setState({isOpen: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
close = () => {
|
||||||
|
this.setState({isOpen: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='MenuOption SubMenuOption menu-option'
|
||||||
|
onMouseEnter={this.handleMouseEnter}
|
||||||
|
onMouseLeave={this.close}
|
||||||
|
>
|
||||||
|
<div className='name menu-name'>{this.props.name}</div>
|
||||||
|
<div className="imageSubmenuTriangle" style={{float: 'right'}}></div>
|
||||||
|
{this.state.isOpen &&
|
||||||
|
<Menu onClose={this.close}>
|
||||||
|
{this.props.children}
|
||||||
|
</Menu>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColorOptionProps = MenuOptionProps & {
|
||||||
|
icon?: "checked" | "sortUp" | "sortDown" | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
class ColorOption extends React.Component<ColorOptionProps> {
|
||||||
|
render() {
|
||||||
|
const {name, icon} = this.props;
|
||||||
|
return (
|
||||||
|
<div className='MenuOption ColorOption menu-option'>
|
||||||
|
<div className='name'>{name}</div>
|
||||||
|
{icon && <div className={'icon ' + icon}></div>}
|
||||||
|
<div className='menu-colorbox'></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SwitchOptionProps = MenuOptionProps & {
|
||||||
|
isOn: boolean,
|
||||||
|
icon?: "checked" | "sortUp" | "sortDown" | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
class SwitchOption extends React.Component<SwitchOptionProps> {
|
||||||
|
handleOnClick = () => {
|
||||||
|
this.props.onClick(this.props.id)
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
const {name, icon, isOn} = this.props;
|
||||||
|
return (
|
||||||
|
<div className='MenuOption SwitchOption menu-option'>
|
||||||
|
<div className='name'>{name}</div>
|
||||||
|
{icon && <div className={`icon ${icon}`}></div>}
|
||||||
|
<div className={isOn ? "octo-switch on" : "octo-switch"}>
|
||||||
|
<div
|
||||||
|
className="octo-switch-inner"
|
||||||
|
onClick={this.handleOnClick}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextOptionProps = MenuOptionProps & {
|
||||||
|
icon?: "checked" | "sortUp" | "sortDown" | undefined,
|
||||||
|
}
|
||||||
|
class TextOption extends React.Component<TextOptionProps> {
|
||||||
|
handleOnClick = () => {
|
||||||
|
this.props.onClick(this.props.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {name, icon} = this.props;
|
||||||
|
return (
|
||||||
|
<div className='MenuOption TextOption menu-option' onClick={this.handleOnClick}>
|
||||||
|
<div className='name'>{name}</div>
|
||||||
|
{icon && <div className={`icon ${icon}`}></div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MenuProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Menu extends React.Component<MenuProps> {
|
||||||
|
static Color = ColorOption
|
||||||
|
static SubMenu = SubMenuOption
|
||||||
|
static Switch = SwitchOption
|
||||||
|
static Separator = SeparatorOption
|
||||||
|
static Text = TextOption
|
||||||
|
|
||||||
|
onBodyClick = (e: MouseEvent) => {
|
||||||
|
this.props.onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
onBodyKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Ignore keydown events on other elements
|
||||||
|
if (e.target !== document.body) { return }
|
||||||
|
if (e.keyCode === 27) {
|
||||||
|
// ESC
|
||||||
|
this.props.onClose()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
document.addEventListener("click", this.onBodyClick)
|
||||||
|
document.addEventListener("keydown", this.onBodyKeyDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
document.removeEventListener("click", this.onBodyClick)
|
||||||
|
document.removeEventListener("keydown", this.onBodyKeyDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="Menu menu noselect">
|
||||||
|
<div className="menu-options">
|
||||||
|
{this.props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
23
src/client/workspaceTree.ts
Normal file
23
src/client/workspaceTree.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Board } from "./board"
|
||||||
|
import { OctoClient } from "./octoClient"
|
||||||
|
import { IBlock } from "./octoTypes"
|
||||||
|
|
||||||
|
class WorkspaceTree {
|
||||||
|
boards: Board[] = []
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private octo: OctoClient) {
|
||||||
|
}
|
||||||
|
|
||||||
|
async sync() {
|
||||||
|
const blocks = await this.octo.getBlocks(undefined, "board")
|
||||||
|
this.rebuild(blocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuild(blocks: IBlock[]) {
|
||||||
|
const boardBlocks = blocks.filter(block => block.type === "board")
|
||||||
|
this.boards = boardBlocks.map(o => new Board(o))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WorkspaceTree }
|
@ -6,7 +6,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.imageAdd {
|
.imageAdd {
|
||||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" style="stroke:black;stroke-width:4;" stroke-opacity="50%"><polyline points="30,50 70,50" /><polyline points="50,30 50,70" /></svg>');
|
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" style="stroke:black;stroke-width:4;" fill="none" stroke-opacity="50%"><polyline points="30,50 70,50" /><polyline points="50,30 50,70" /></svg>');
|
||||||
background-size: 100% 100%;
|
background-size: 100% 100%;
|
||||||
min-width: 24px;
|
min-width: 24px;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
@ -19,9 +19,32 @@
|
|||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*-- Menu images --*/
|
||||||
|
|
||||||
.imageSubmenuTriangle {
|
.imageSubmenuTriangle {
|
||||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="50,35 75,50 50,65" style="fill:black;stroke:none;" fill-opacity="70%" /></svg>');
|
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="50,35 75,50 50,65" style="fill:black;stroke:none;" fill-opacity="70%" /></svg>');
|
||||||
background-size: 100% 100%;
|
background-size: 100% 100%;
|
||||||
min-width: 24px;
|
min-width: 24px;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.imageMenuCheck {
|
||||||
|
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" style="stroke:black;stroke-width:8;" fill="none" stroke-opacity="50%"><polyline points="20,60 40,80 80,40" /></svg>');
|
||||||
|
background-size: 100% 100%;
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageMenuSortUp {
|
||||||
|
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" style="stroke:black;stroke-width:8;" fill="none" stroke-opacity="50%"><polyline points="50,20 50,80" /><polyline points="30,40 50,20 70,40" /></svg>');
|
||||||
|
background-size: 100% 100%;
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageMenuSortDown {
|
||||||
|
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" style="stroke:black;stroke-width:8;" fill="none" stroke-opacity="50%"><polyline points="50,20 50,80" /><polyline points="30,60 50,80 70,60" /></svg>');
|
||||||
|
background-size: 100% 100%;
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
@ -5,6 +5,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: rgb(55, 53, 47);
|
color: rgb(55, 53, 47);
|
||||||
}
|
}
|
||||||
@ -36,7 +40,7 @@ hr {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
.page-header {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
background-color: #eeeeee;
|
background-color: #eeeeee;
|
||||||
@ -44,16 +48,12 @@ header {
|
|||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
header a {
|
.page-header a {
|
||||||
color: #505050;
|
color: #505050;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
.page-loading {
|
||||||
padding: 10px 20px;
|
margin: 50px auto;
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
padding: 10px 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title, h1 {
|
.title, h1 {
|
||||||
@ -64,19 +64,90 @@ footer {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* OCTO */
|
/* App frame */
|
||||||
|
|
||||||
|
#octo-tasks-app {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#octo-tasks-app #frame,
|
||||||
|
#octo-tasks-app #main,
|
||||||
|
#octo-tasks-app .BoardPage {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
|
||||||
|
.octo-workspace {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.octo-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
min-height: 100%;
|
||||||
|
background-color: rgb(247, 246, 243);
|
||||||
|
min-width: 230px;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.octo-sidebar-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 3px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.octo-sidebar-title {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.octo-sidebar-item:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.octo-sidebar-item .octo-button:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main app */
|
||||||
|
|
||||||
|
.octo-app {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.octo-frame {
|
.octo-frame {
|
||||||
padding: 10px 50px 50px 50px;
|
flex: 1 1 auto;
|
||||||
min-width: 1000px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: scroll;
|
||||||
|
|
||||||
|
padding: 10px 95px 50px 95px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.octo-board {
|
.octo-board {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.octo-controls {
|
.octo-controls {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
||||||
@ -119,12 +190,14 @@ footer {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
|
flex: 0 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.octo-board-column {
|
.octo-board-column {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
width: 260px;
|
width: 260px;
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
@ -140,6 +213,7 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.octo-board-card {
|
.octo-board-card {
|
||||||
|
flex: 0 0 auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -374,6 +448,7 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.octo-icontitle {
|
.octo-icontitle {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -386,6 +461,7 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.octo-board-card > .octo-icontitle {
|
.octo-board-card > .octo-icontitle {
|
||||||
|
flex: 1 1 auto;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -552,6 +628,7 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.octo-table-cell {
|
.octo-table-cell {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
||||||
@ -560,7 +637,7 @@ footer {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 5px 8px 6px 8px;
|
padding: 5px 8px 6px 8px;
|
||||||
|
|
||||||
width: 240px;
|
width: 150px;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@ -570,6 +647,10 @@ footer {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.octo-table-cell.title-cell {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
.octo-table-cell .octo-propertyvalue {
|
.octo-table-cell .octo-propertyvalue {
|
||||||
line-height: 17px;
|
line-height: 17px;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
const webpack = require("webpack");
|
const webpack = require("webpack")
|
||||||
const path = require("path");
|
const path = require("path")
|
||||||
const CopyPlugin = require("copy-webpack-plugin");
|
const CopyPlugin = require("copy-webpack-plugin")
|
||||||
var HtmlWebpackPlugin = require('html-webpack-plugin');
|
var HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||||
|
|
||||||
const outpath = path.resolve(__dirname, "pack");
|
const outpath = path.resolve(__dirname, "pack")
|
||||||
|
|
||||||
function makeCommonConfig() {
|
function makeCommonConfig() {
|
||||||
const commonConfig = {
|
const commonConfig = {
|
||||||
@ -26,6 +26,14 @@ function makeCommonConfig() {
|
|||||||
loader: "file-loader",
|
loader: "file-loader",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
test: /\.s[ac]ss$/i,
|
||||||
|
use: [
|
||||||
|
'style-loader',
|
||||||
|
'css-loader',
|
||||||
|
'sass-loader',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
test: /\.(tsx?|js|jsx|html)$/,
|
test: /\.(tsx?|js|jsx|html)$/,
|
||||||
use: [
|
use: [
|
||||||
],
|
],
|
||||||
@ -50,36 +58,21 @@ function makeCommonConfig() {
|
|||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
inject: true,
|
inject: true,
|
||||||
title: "OCTO",
|
title: "OCTO",
|
||||||
chunks: [],
|
chunks: ["main"],
|
||||||
template: "html-templates/index.ejs",
|
template: "html-templates/page.ejs",
|
||||||
filename: 'index.html'
|
filename: 'index.html'
|
||||||
}),
|
}),
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
inject: true,
|
|
||||||
title: "OCTO - Boards",
|
|
||||||
chunks: ["boardsPage"],
|
|
||||||
template: "html-templates/page.ejs",
|
|
||||||
filename: 'boards.html'
|
|
||||||
}),
|
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
inject: true,
|
|
||||||
title: "OCTO",
|
|
||||||
chunks: ["boardPage"],
|
|
||||||
template: "html-templates/page.ejs",
|
|
||||||
filename: 'board.html'
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
entry: {
|
entry: {
|
||||||
boardsPage: "./src/client/boardsPage.ts",
|
main: "./src/client/main.tsx",
|
||||||
boardPage: "./src/client/boardPage.tsx"
|
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
filename: "[name].js",
|
filename: "[name].js",
|
||||||
path: outpath
|
path: outpath
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
return commonConfig;
|
return commonConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = makeCommonConfig;
|
module.exports = makeCommonConfig
|
||||||
|
Loading…
x
Reference in New Issue
Block a user