diff --git a/.gitignore b/.gitignore
index d8b88e56b..e3dd6f49c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,4 +35,4 @@ bin
debug
__debug_bin
files
-octo.db
+octo*.db
diff --git a/Makefile b/Makefile
index eac6e42a4..18d6ee458 100644
--- a/Makefile
+++ b/Makefile
@@ -26,6 +26,11 @@ watch:
prebuild:
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:
rm -rf bin
diff --git a/README.md b/README.md
index 1bb1ac96e..021dac73f 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
## Build instructions
```
-npm i
+make prebuild
make
```
diff --git a/html-templates/index.ejs b/html-templates/index.ejs
deleted file mode 100644
index 024e53969..000000000
--- a/html-templates/index.ejs
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
-
-
+
+
diff --git a/server/main/config.go b/server/main/config.go
index b70eeb125..b7ab3f2f4 100644
--- a/server/main/config.go
+++ b/server/main/config.go
@@ -1,65 +1,46 @@
package main
import (
- "encoding/json"
"log"
- "os"
+
+ "github.com/spf13/viper"
)
// Configuration is the app configuration stored in a json file
type Configuration struct {
- ServerRoot string `json:"serverRoot"`
- Port int `json:"port"`
- DBType string `json:"dbtype"`
- DBConfigString string `json:"dbconfig"`
- UseSSL bool `json:"useSSL"`
- WebPath string `json:"webpath"`
- FilesPath string `json:"filespath"`
+ ServerRoot string `json:"serverRoot" mapstructure:"serverRoot"`
+ Port int `json:"port" mapstructure:"port"`
+ DBType string `json:"dbtype" mapstructure:"dbtype"`
+ DBConfigString string `json:"dbconfig" mapstructure:"dbconfig"`
+ UseSSL bool `json:"useSSL" mapstructure:"useSSL"`
+ WebPath string `json:"webpath" mapstructure:"webpath"`
+ FilesPath string `json:"filespath" mapstructure:"filespath"`
}
-func readConfigFile() Configuration {
- fileName := "config.json"
- if !fileExists(fileName) {
- log.Println(`config.json not found, using default settings`)
- return Configuration{}
+func readConfigFile() (*Configuration, error) {
+ viper.SetConfigName("config") // name of config file (without extension)
+ viper.SetConfigType("json") // REQUIRED if the config file does not have the extension in the name
+ viper.AddConfigPath(".") // optionally look for config in the working directory
+ 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{}
- err := decoder.Decode(&configuration)
+ err = viper.Unmarshal(&configuration)
if err != nil {
- log.Fatal("Invalid config.json", 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"
+ return nil, err
}
log.Println("readConfigFile")
log.Printf("%+v", configuration)
- return configuration
+ return &configuration, nil
}
diff --git a/server/main/main.go b/server/main/main.go
index 82f4a5786..f49eb7156 100644
--- a/server/main/main.go
+++ b/server/main/main.go
@@ -17,25 +17,10 @@ import (
"os/signal"
"github.com/gorilla/mux"
- "github.com/gorilla/websocket"
)
-var config Configuration
-
-// 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
- },
-}
+var wsServer *WSServer
+var config *Configuration
// ----------------------------------------------------------------------------------------------------
// HTTP handlers
@@ -54,6 +39,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
@@ -63,12 +55,15 @@ func handleGetBlocks(w http.ResponseWriter, r *http.Request) {
blockType := query.Get("type")
var blocks []string
- if len(blockType) > 0 {
+ if len(blockType) > 0 && len(parentID) > 0 {
blocks = getBlocksWithParentAndType(parentID, blockType)
+ } else if len(blockType) > 0 {
+ blocks = getBlocksWithType(blockType)
} else {
blocks = 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[:], ",") + `]`
jsonResponse(w, 200, response)
}
@@ -132,7 +127,7 @@ func handlePostBlocks(w http.ResponseWriter, r *http.Request) {
insertBlock(block, string(jsonBytes))
}
- broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
+ wsServer.broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
log.Printf("POST Blocks %d block(s)", len(blockMaps))
jsonResponse(w, 200, "{}")
@@ -152,7 +147,7 @@ func handleDeleteBlock(w http.ResponseWriter, r *http.Request) {
deleteBlock(blockID)
- broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
+ wsServer.broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
log.Printf("DELETE Block %s", blockID)
jsonResponse(w, 200, "{}")
@@ -294,66 +289,6 @@ func errorResponse(w http.ResponseWriter, code int, message string) {
// ----------------------------------------------------------------------------------------------------
// 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 {
process, err := os.FindProcess(pid)
if err != nil {
@@ -383,7 +318,12 @@ func monitorPid(pid int) {
func main() {
// 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
pMonitorPid := flag.Int("monitorpid", -1, "a process ID")
@@ -400,14 +340,14 @@ func main() {
config.Port = *pPort
}
+ wsServer = NewWSServer()
+
r := mux.NewRouter()
// Static files
- handleStaticFile(r, "/", "index.html", "text/html; charset=utf-8")
- handleStaticFile(r, "/boards", "boards.html", "text/html; charset=utf-8")
- handleStaticFile(r, "/board", "board.html", "text/html; charset=utf-8")
+ handleDefault(r, "/")
- handleStaticFile(r, "/boardsPage.js", "boardsPage.js", "text/javascript; charset=utf-8")
+ handleStaticFile(r, "/board", "board.html", "text/html; 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")
@@ -429,7 +369,7 @@ func main() {
r.HandleFunc("/api/v1/blocks/import", handleImport).Methods("POST")
// WebSocket
- r.HandleFunc("/ws/onchange", handleWebSocketOnChange)
+ r.HandleFunc("/ws/onchange", wsServer.handleWebSocketOnChange)
// Files
r.HandleFunc("/files/{filename}", handleServeFile).Methods("GET")
diff --git a/server/main/octoDatabase.go b/server/main/octoDatabase.go
index 4a4568fa1..3b98953f2 100644
--- a/server/main/octoDatabase.go
+++ b/server/main/octoDatabase.go
@@ -155,6 +155,32 @@ func getBlocksWithParent(parentID string) []string {
return blocksFromRows(rows)
}
+func getBlocksWithType(blockType 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 getSubTree(blockID string) []string {
query := `WITH latest AS
(
diff --git a/server/main/websockets.go b/server/main/websockets.go
new file mode 100644
index 000000000..0316ac3fe
--- /dev/null
+++ b/server/main/websockets.go
@@ -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()
+ }
+ }
+ }
+ }
+}
diff --git a/src/client/block.ts b/src/client/block.ts
index d77acbf1f..10258cfa6 100644
--- a/src/client/block.ts
+++ b/src/client/block.ts
@@ -1,4 +1,4 @@
-import { IBlock, IProperty } from "./octoTypes"
+import { IBlock } from "./octoTypes"
import { Utils } from "./utils"
class Block implements IBlock {
@@ -9,7 +9,7 @@ class Block implements IBlock {
icon?: string
url?: string
order: number
- properties: IProperty[] = []
+ properties: Record
= {}
createAt: number = Date.now()
updateAt: number = 0
deleteAt: number = 0
@@ -37,33 +37,20 @@ class Block implements IBlock {
this.icon = block.icon
this.url = block.url
this.order = block.order
- this.properties = block.properties ? block.properties.map((o: IProperty) => ({...o})) : [] // Deep clone
this.createAt = block.createAt || now
this.updateAt = block.updateAt || now
this.deleteAt = block.deleteAt || 0
- }
- static getPropertyValue(block: IBlock, id: string): string | undefined {
- if (!block.properties) { return undefined }
- const property = block.properties.find( o => o.id === id )
- if (!property) { return undefined }
- return 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
+ if (Array.isArray(block.properties)) {
+ // HACKHACK: Port from old schema
+ this.properties = {}
+ for (const property of block.properties) {
+ if (property.id) {
+ this.properties[property.id] = property.value
+ }
+ }
} else {
- const newProperty: IProperty = { id, value }
- block.properties.push(newProperty)
+ this.properties = { ...block.properties || {} }
}
}
}
diff --git a/src/client/boardPage.tsx b/src/client/boardPage.tsx
index c7334e3d5..bc9009a62 100644
--- a/src/client/boardPage.tsx
+++ b/src/client/boardPage.tsx
@@ -3,10 +3,10 @@ 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 { PageHeader } from "./components/pageHeader"
+import { WorkspaceComponent } from "./components/workspaceComponent"
import { FlashMessage } from "./flashMessage"
import { Mutator } from "./mutator"
import { OctoClient } from "./octoClient"
@@ -14,6 +14,7 @@ import { OctoListener } from "./octoListener"
import { IBlock, IPageController } from "./octoTypes"
import { UndoManager } from "./undomanager"
import { Utils } from "./utils"
+import { WorkspaceTree } from "./workspaceTree"
class BoardPage implements IPageController {
boardTitle: HTMLElement
@@ -22,10 +23,11 @@ class BoardPage implements IPageController {
groupByButton: HTMLElement
groupByLabel: HTMLElement
- boardId: string
- viewId: string
+ boardId?: string
+ viewId?: string
- boardTree: BoardTree
+ workspaceTree: WorkspaceTree
+ boardTree?: BoardTree
view: BoardView
updateTitleTimeout: number
@@ -40,26 +42,18 @@ class BoardPage implements IPageController {
constructor() {
const queryString = new URLSearchParams(window.location.search)
- if (!queryString.has("id")) {
- // No id, redirect to home
- window.location.href = "/"
- return
- }
+ const boardId = queryString.get("id")
+ const viewId = queryString.get("v")
- this.boardId = queryString.get("id")
- this.viewId = queryString.get("v")
+ this.layoutPage()
+
+ this.workspaceTree = new WorkspaceTree(this.octo)
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()
- })
+ if (boardId) {
+ this.attachToBoard(boardId, viewId)
} else {
- // Show error
+ this.sync()
}
document.body.addEventListener("keydown", async (e) => {
@@ -89,63 +83,54 @@ class BoardPage implements IPageController {
this.render()
}
+ private layoutPage() {
+ const root = Utils.getElementById("octo-tasks-app")
+ root.innerText = ""
+
+ const header = root.appendChild(document.createElement("div"))
+ header.id = "header"
+
+ const main = root.appendChild(document.createElement("div"))
+ main.id = "main"
+
+ const overlay = root.appendChild(document.createElement("div"))
+ overlay.id = "overlay"
+
+ const modal = root.appendChild(document.createElement("div"))
+ modal.id = "modal"
+ }
+
render() {
const { octo, boardTree } = this
- const { board, activeView } = boardTree
+ const { board, activeView } = boardTree || {}
const mutator = new Mutator(octo)
- const rootElement = Utils.getElementById("main")
+ const mainElement = Utils.getElementById("main")
+
+ ReactDOM.render(
+ ,
+ Utils.getElementById("header")
+ )
if (board) {
Utils.setFavicon(board.icon)
- } else {
- ReactDOM.render(
- Loading...
,
- rootElement
- )
- return
+ document.title = `OCTO - ${board.title} | ${activeView.title}`
}
- if (activeView) {
- document.title = `OCTO - ${board.title} | ${activeView.title}`
+ ReactDOM.render(
+ ,
+ mainElement
+ )
- switch (activeView.viewType) {
- case "board": {
- ReactDOM.render(
- ,
- rootElement
- )
- break
- }
-
- case "table": {
- ReactDOM.render(
- ,
- rootElement
- )
- break
- }
-
- default: {
- Utils.assertFailure(`render() Unhandled viewType: ${activeView.viewType}`)
- }
- }
-
- if (boardTree && boardTree.board && this.shownCardTree) {
- ReactDOM.render(
- { this.showCard(undefined) }}>,
- Utils.getElementById("overlay")
- )
- } else {
- ReactDOM.render(
- ,
- Utils.getElementById("overlay")
- )
- }
+ if (boardTree && boardTree.board && this.shownCardTree) {
+ ReactDOM.render(
+ { this.showCard(undefined) }}>,
+ Utils.getElementById("overlay")
+ )
} else {
ReactDOM.render(
- Loading...
,
- rootElement
+ ,
+ Utils.getElementById("overlay")
)
}
@@ -164,7 +149,7 @@ class BoardPage implements IPageController {
boardTree={boardTree}
pageX={pageX}
pageY={pageY}
- onClose={() => {this.showFilter(undefined)}}
+ onClose={() => { this.showFilter(undefined) }}
>
,
Utils.getElementById("modal")
@@ -174,22 +159,38 @@ class BoardPage implements IPageController {
}
}
+ private attachToBoard(boardId: string, viewId?: string) {
+ this.boardId = boardId
+ this.viewId = viewId
+
+ this.boardTree = new BoardTree(this.octo, boardId)
+
+ this.boardListener.open(boardId, (blockId: string) => {
+ console.log(`octoListener.onChanged: ${blockId}`)
+ this.sync()
+ })
+
+ this.sync()
+ }
+
async sync() {
- const { boardTree } = this
+ const { workspaceTree, boardTree } = this
- await boardTree.sync()
+ await workspaceTree.sync()
+ if (boardTree) {
+ await boardTree.sync()
- // Default to first view
- if (!this.viewId) {
- this.viewId = boardTree.views[0].id
+ // 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}`)
}
- 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()
}
@@ -215,6 +216,15 @@ class BoardPage implements IPageController {
this.render()
}
+ showBoard(boardId: string) {
+ if (this.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.viewId = viewId
this.boardTree.setActiveView(this.viewId)
@@ -227,6 +237,11 @@ class BoardPage implements IPageController {
this.filterAnchorElement = ahchorElement
this.render()
}
+
+ setSearchText(text?: string) {
+ this.boardTree.setSearchText(text)
+ this.render()
+ }
}
export { BoardPage }
diff --git a/src/client/boardTree.ts b/src/client/boardTree.ts
index 74c89dfbb..0d4d84e93 100644
--- a/src/client/boardTree.ts
+++ b/src/client/boardTree.ts
@@ -17,6 +17,7 @@ class BoardTree {
activeView?: BoardView
groupByProperty?: IPropertyTemplate
+ private searchText?: string
private allCards: IBlock[] = []
get allBlocks(): IBlock[] {
return [this.board, ...this.views, ...this.allCards]
@@ -93,8 +94,18 @@ class BoardTree {
this.applyFilterSortAndGroup()
}
+ getSearchText(): string | undefined {
+ return this.searchText
+ }
+
+ setSearchText(text?: string) {
+ this.searchText = text
+ this.applyFilterSortAndGroup()
+ }
+
applyFilterSortAndGroup() {
this.cards = this.filterCards(this.allCards)
+ this.cards = this.searchFilterCards(this.cards)
this.cards = this.sortCards(this.cards)
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) {
const { board } = this
@@ -125,16 +145,16 @@ class BoardTree {
const groupByPropertyId = this.groupByProperty.id
this.emptyGroupCards = this.cards.filter(o => {
- const property = o.properties.find(p => p.id === groupByPropertyId)
- return !property || !property.value || !this.groupByProperty.options.find(option => option.value === property.value)
+ const propertyValue = o.properties[groupByPropertyId]
+ return !propertyValue || !this.groupByProperty.options.find(option => option.value === propertyValue)
})
const propertyOptions = this.groupByProperty.options || []
for (const option of propertyOptions) {
const cards = this.cards
.filter(o => {
- const property = o.properties.find(p => p.id === groupByPropertyId)
- return property && property.value === option.value
+ const propertyValue = o.properties[groupByPropertyId]
+ return propertyValue && propertyValue === option.value
})
const group: Group = {
@@ -200,10 +220,8 @@ class BoardTree {
if (b.title && !a.title) { return 1 }
if (!a.title && !b.title) { return a.createAt - b.createAt }
- const aProperty = a.properties.find(o => o.id === sortPropertyId)
- const bProperty = b.properties.find(o => o.id === sortPropertyId)
- const aValue = aProperty ? aProperty.value : ""
- const bValue = bProperty ? bProperty.value : ""
+ const aValue = a.properties[sortPropertyId] || ""
+ const bValue = b.properties[sortPropertyId] || ""
let result = 0
if (template.type === "select") {
// Always put empty values at the bottom
diff --git a/src/client/boardsPage.ts b/src/client/boardsPage.ts
deleted file mode 100644
index ee5a3c15b..000000000
--- a/src/client/boardsPage.ts
+++ /dev/null
@@ -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")
diff --git a/src/client/cardFilter.ts b/src/client/cardFilter.ts
index fe7b982c3..82641f89c 100644
--- a/src/client/cardFilter.ts
+++ b/src/client/cardFilter.ts
@@ -1,7 +1,7 @@
import { IPropertyTemplate } from "./board"
import { FilterClause } from "./filterClause"
import { FilterGroup } from "./filterGroup"
-import { IBlock, IProperty } from "./octoTypes"
+import { IBlock } from "./octoTypes"
import { Utils } from "./utils"
class CardFilter {
@@ -39,8 +39,7 @@ class CardFilter {
}
static isClauseMet(filter: FilterClause, templates: IPropertyTemplate[], card: IBlock): boolean {
- const property = card.properties.find(o => o.id === filter.propertyId)
- const value = property?.value
+ const value = card.properties[filter.propertyId]
switch (filter.condition) {
case "includes": {
if (filter.values.length < 1) { break } // No values = ignore clause (always met)
@@ -61,7 +60,7 @@ class CardFilter {
return true
}
- static propertiesThatMeetFilterGroup(filterGroup: FilterGroup, templates: IPropertyTemplate[]): IProperty[] {
+ static propertiesThatMeetFilterGroup(filterGroup: FilterGroup, templates: IPropertyTemplate[]): Record {
// TODO: Handle filter groups
const filters = filterGroup.filters.filter(o => !FilterGroup.isAnInstanceOf(o))
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)
switch (filterClause.condition) {
case "includes": {
diff --git a/src/client/components/boardComponent.tsx b/src/client/components/boardComponent.tsx
index 1b75c6ef3..a2ead7735 100644
--- a/src/client/components/boardComponent.tsx
+++ b/src/client/components/boardComponent.tsx
@@ -4,7 +4,6 @@ import { Block } from "../block"
import { BlockIcons } from "../blockIcons"
import { IPropertyOption } from "../board"
import { BoardTree } from "../boardTree"
-import { ISortOption } from "../boardView"
import { CardFilter } from "../cardFilter"
import { Constants } from "../constants"
import { Menu } from "../menu"
@@ -25,15 +24,23 @@ type Props = {
type State = {
isHoverOnCover: boolean
+ isSearching: boolean
}
class BoardComponent extends React.Component {
private draggedCard: IBlock
private draggedHeaderOption: IPropertyOption
+ private searchFieldRef = React.createRef()
constructor(props: Props) {
super(props)
- this.state = { isHoverOnCover: false }
+ this.state = { isHoverOnCover: false, isSearching: !!this.props.boardTree?.getSearchText() }
+ }
+
+ componentDidUpdate(prevPros: Props, prevState: State) {
+ if (this.state.isSearching && !prevState.isSearching) {
+ this.searchFieldRef.current.focus()
+ }
}
render() {
@@ -87,9 +94,18 @@ class BoardComponent extends React.Component {
{ this.groupByClicked(e) }}>
Group by {boardTree.groupByProperty?.name}
- { this.filterClicked(e) }}>Filter
- { this.sortClicked(e) }}>Sort
- Search
+ { this.filterClicked(e) }}>Filter
+ { OctoUtils.showSortMenu(e, mutator, boardTree) }}>Sort
+ {this.state.isSearching
+ ? { this.searchChanged(text) }}
+ onKeyDown={(e) => { this.onSearchKeyDown(e) }}>
+ : { this.setState({ ...this.state, isSearching: true }) }}>Search
+ }
{ this.optionsClicked(e) }}>
{ this.addCard(undefined) }}>New
@@ -217,7 +233,7 @@ class BoardComponent extends React.Component