Merge branch 'main' into sass-aside-component

This commit is contained in:
Jesús Espino 2020-10-14 12:40:45 +02:00
commit 304eee5d44
31 changed files with 885 additions and 546 deletions

2
.gitignore vendored
View file

@ -35,4 +35,4 @@ bin
debug
__debug_bin
files
octo.db
octo*.db

View file

@ -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

View file

@ -3,7 +3,7 @@
## Build instructions
```
npm i
make prebuild
make
```

View file

@ -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>

View file

@ -11,21 +11,8 @@
<link rel="stylesheet" href="/colors.css">
</head>
<body class="container">
<header id="header">
<a href="/">OCTO</a>
</header>
<main id="main">
</main>
<footer id="footer">
</footer>
<div id="overlay">
</div>
<div id="modal">
<body>
<div id="octo-tasks-app">
</div>
</body>

View file

@ -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
}

View file

@ -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")

View file

@ -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
(

128
server/main/websockets.go Normal file
View 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()
}
}
}
}
}

View file

@ -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<string, string> = {}
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 || {} }
}
}
}

View file

@ -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(
<PageHeader />,
Utils.getElementById("header")
)
if (board) {
Utils.setFavicon(board.icon)
} else {
ReactDOM.render(
<div>Loading...</div>,
rootElement
)
return
document.title = `OCTO - ${board.title} | ${activeView.title}`
}
if (activeView) {
document.title = `OCTO - ${board.title} | ${activeView.title}`
ReactDOM.render(
<WorkspaceComponent mutator={mutator} workspaceTree={this.workspaceTree} boardTree={this.boardTree} pageController={this} />,
mainElement
)
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")
)
}
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>Loading...</div>,
rootElement
<div />,
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) }}
>
</FilterComponent>,
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 }

View file

@ -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

View file

@ -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")

View file

@ -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<string, any> {
// 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": {

View file

@ -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<Props, State> {
private draggedCard: IBlock
private draggedHeaderOption: IPropertyOption
private searchFieldRef = React.createRef<Editable>()
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<Props, State> {
<div className="octo-button" id="groupByButton" onClick={(e) => { this.groupByClicked(e) }}>
Group by <span style={groupByStyle} id="groupByLabel">{boardTree.groupByProperty?.name}</span>
</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="octo-button">Search</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) => { OctoUtils.showSortMenu(e, mutator, boardTree) }}>Sort</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 filled" onClick={() => { this.addCard(undefined) }}>New</div>
</div>
@ -217,7 +233,7 @@ class BoardComponent extends React.Component<Props, State> {
const properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
const card = new Block({ type: "card", parentId: boardTree.board.id, properties })
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) })
}
@ -258,37 +274,13 @@ class BoardComponent extends React.Component<Props, State> {
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) {
const { boardTree } = this.props
Menu.shared.options = [
{ 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) => {
@ -297,11 +289,37 @@ class BoardComponent extends React.Component<Props, State> {
Archiver.exportBoardTree(boardTree)
break
}
case "testAdd100Cards": {
this.testAddCards(100)
}
case "testAdd1000Cards": {
this.testAddCards(1000)
}
}
}
Menu.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) {
const { mutator, boardTree } = this.props
const { activeView } = boardTree
@ -364,7 +382,7 @@ class BoardComponent extends React.Component<Props, State> {
if (draggedCard) {
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) {
await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty.id, propertyValue, "drag card")
}
@ -380,6 +398,19 @@ class BoardComponent extends React.Component<Props, State> {
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.pageController.setSearchText(undefined)
e.preventDefault()
}
}
searchChanged(text?: string) {
this.props.pageController.setSearchText(text)
}
}
export { BoardComponent }

View file

@ -102,7 +102,6 @@ class Editable extends React.Component<Props, State> {
this.text = newText
this.elementRef.current.classList.remove("active")
if (onBlur) { onBlur() }
}}

View 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 }

View file

@ -0,0 +1,126 @@
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
pageController: IPageController
workspaceTree: WorkspaceTree,
boardTree?: BoardTree
}
class Sidebar extends React.Component<Props> {
render() {
const { workspaceTree } = this.props
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, pageController, 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 () => { pageController.showBoard(nextBoardId!) },
async () => { pageController.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) {
const { pageController } = this.props
pageController.showBoard(board.id)
}
async addBoardClicked() {
const { mutator, boardTree, pageController } = this.props
const oldBoardId = boardTree?.board?.id
const board = new Board()
await mutator.insertBlock(
board,
"add board",
async () => { pageController.showBoard(board.id) },
async () => { if (oldBoardId) { pageController.showBoard(oldBoardId) } })
await mutator.insertBlock(board)
}
}
export { Sidebar }

View file

@ -4,7 +4,6 @@ import { Block } from "../block"
import { BlockIcons } from "../blockIcons"
import { IPropertyTemplate } from "../board"
import { BoardTree } from "../boardTree"
import { ISortOption } from "../boardView"
import { CsvExporter } from "../csvExporter"
import { Menu } from "../menu"
import { Mutator } from "../mutator"
@ -23,16 +22,24 @@ type Props = {
type State = {
isHoverOnCover: boolean
isSearching: boolean
}
class TableComponent extends React.Component<Props, State> {
private draggedHeaderTemplate: IPropertyTemplate
private cardIdToRowMap = new Map<string, React.RefObject<TableRow>>()
private cardIdToFocusOnRender: string
private searchFieldRef = React.createRef<Editable>()
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() {
@ -82,8 +89,17 @@ class TableComponent extends React.Component<Props, State> {
<div className="octo-spacer"></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={ hasSort ? "octo-button active" : "octo-button"} onClick={(e) => { this.sortClicked(e) }}>Sort</div>
<div className="octo-button">Search</div>
<div className={ hasSort ? "octo-button active" : "octo-button"} onClick={(e) => { OctoUtils.showSortMenu(e, mutator, boardTree) }}>Sort</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 filled" onClick={() => { this.addCard(true) }}>New</div>
</div>
@ -95,7 +111,7 @@ class TableComponent extends React.Component<Props, State> {
{/* Headers */}
<div className="octo-table-header" id="mainBoardHeader">
<div className="octo-table-cell" id="mainBoardHeader">
<div className="octo-table-cell title-cell" id="mainBoardHeader">
<div
className="octo-label"
style={{ cursor: "pointer" }}
@ -231,32 +247,6 @@ class TableComponent extends React.Component<Props, State> {
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) {
const { boardTree } = this.props
@ -401,6 +391,19 @@ class TableComponent extends React.Component<Props, State> {
const destIndex = template ? board.cardProperties.indexOf(template) : 0
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.pageController.setSearchText(undefined)
e.preventDefault()
}
}
searchChanged(text?: string) {
this.props.pageController.setSearchText(text)
}
}
export { TableComponent }

View file

@ -36,7 +36,7 @@ class TableRow extends React.Component<Props, State> {
{/* 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-icon">{card.icon}</div>
<Editable

View file

@ -0,0 +1,57 @@
import React from "react"
import { BoardTree } from "../boardTree"
import { Mutator } from "../mutator"
import { IPageController } 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
pageController: IPageController
}
class WorkspaceComponent extends React.Component<Props> {
render() {
const { mutator, boardTree, workspaceTree, pageController } = this.props
const element =
<div className="octo-workspace">
<Sidebar mutator={mutator} pageController={pageController} workspaceTree={workspaceTree} boardTree={boardTree}></Sidebar>
{this.mainComponent()}
</div>
return element
}
private mainComponent() {
const { mutator, boardTree, pageController } = this.props
const { activeView } = boardTree || {}
if (!activeView) {
return <div></div>
}
switch (activeView?.viewType) {
case "board": {
return <BoardComponent mutator={mutator} boardTree={boardTree} pageController={pageController} />
}
case "table": {
return <TableComponent mutator={mutator} boardTree={boardTree} pageController={pageController} />
}
default: {
Utils.assertFailure(`render() Unhandled viewType: ${activeView.viewType}`)
return <div></div>
}
}
}
}
export { WorkspaceComponent }

View file

@ -49,10 +49,10 @@ class CsvExporter {
cards.forEach(card => {
const row: string[] = []
visibleProperties.forEach(template => {
const property = card.properties.find(o => o.id === template.id)
const displayValue = OctoUtils.propertyDisplayValue(card, property, template) || ""
const propertyValue = card.properties[template.id]
const displayValue = OctoUtils.propertyDisplayValue(card, propertyValue, template) || ""
if (template.type === "number") {
const numericValue = property?.value ? Number(property?.value).toString() : undefined
const numericValue = propertyValue ? Number(propertyValue).toString() : undefined
row.push(numericValue)
} else {
// Export as string

View file

@ -4,6 +4,7 @@ type MenuOption = {
id: string,
name: string,
isOn?: boolean,
icon?: "checked" | "sortUp" | "sortDown" | 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)
}
} 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 = () => {
this.hideSubMenu()
}

View file

@ -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
if (!description) {
@ -59,10 +59,12 @@ class Mutator {
await undoManager.perform(
async () => {
await beforeRedo?.()
await octo.deleteBlock(block.id)
},
async () => {
await octo.insertBlock(block)
await afterUndo?.()
},
description
)
@ -243,9 +245,9 @@ class Mutator {
}
})
cards.forEach(card => {
if (card.properties.findIndex(o => o.id === propertyId) !== -1) {
if (card.properties[propertyId]) {
oldBlocks.push(new Block(card))
card.properties = card.properties.filter(o => o.id !== propertyId)
delete card.properties[propertyId]
changedBlocks.push(card)
}
})
@ -364,13 +366,12 @@ class Mutator {
// Change the value on all cards that have this property too
for (const card of cards) {
card.properties.forEach(property => {
if (property.id === propertyTemplate.id && property.value === oldValue) {
oldBlocks.push(new Block(card))
property.value = value
changedBlocks.push(card)
}
})
const propertyValue = card.properties[propertyTemplate.id]
if (propertyValue && propertyValue === oldValue) {
oldBlocks.push(new Block(card))
card.properties[propertyTemplate.id] = value
changedBlocks.push(card)
}
}
await undoManager.perform(
@ -407,14 +408,14 @@ class Mutator {
async changePropertyValue(block: IBlock, propertyId: string, value?: string, description: string = "change property") {
const { octo, undoManager } = this
const oldValue = Block.getPropertyValue(block, propertyId)
const oldValue = block.properties[propertyId]
await undoManager.perform(
async () => {
Block.setProperty(block, propertyId, value)
block.properties[propertyId] = value
await octo.updateBlock(block)
},
async () => {
Block.setProperty(block, propertyId, oldValue)
block.properties[propertyId] = oldValue
await octo.updateBlock(block)
},
description

View file

@ -64,9 +64,18 @@ class OctoClient {
fixBlocks(blocks: IBlock[]) {
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
}
}
}

View file

@ -1,9 +1,3 @@
// A property on a bock
interface IProperty {
id: string
value?: string
}
// A block is the fundamental data type
interface IBlock {
id: string
@ -11,10 +5,10 @@ interface IBlock {
type: string
title?: string
url?: string
url?: string // TODO: Move to properties (_url)
icon?: string
order: number
properties: IProperty[]
properties: Record<string, string>
createAt: number
updateAt: number
@ -24,8 +18,10 @@ interface IBlock {
// These are methods exposed by the top-level page to components
interface IPageController {
showCard(card: IBlock): Promise<void>
showBoard(boardId: string): void
showView(viewId: string): void
showFilter(anchorElement?: HTMLElement): void
setSearchText(text?: string): void
}
export { IProperty, IBlock, IPageController }
export { IBlock, IPageController }

View file

@ -1,11 +1,11 @@
import React from "react"
import { IPropertyTemplate } from "./board"
import { BoardTree } from "./boardTree"
import { BoardView } from "./boardView"
import { BoardView, ISortOption } from "./boardView"
import { Editable } from "./components/editable"
import { Menu, MenuOption } from "./menu"
import { Mutator } from "./mutator"
import { IBlock, IPageController, IProperty } from "./octoTypes"
import { IBlock, IPageController } from "./octoTypes"
import { Utils } from "./utils"
class OctoUtils {
@ -78,7 +78,7 @@ class OctoUtils {
Menu.shared.showAtElement(e.target as HTMLElement)
}
static propertyDisplayValue(block: IBlock, property: IProperty, propertyTemplate: IPropertyTemplate) {
static propertyDisplayValue(block: IBlock, propertyValue: string | undefined, propertyTemplate: IPropertyTemplate) {
let displayValue: string
switch (propertyTemplate.type) {
case "createdTime":
@ -88,7 +88,7 @@ class OctoUtils {
displayValue = Utils.displayDateTime(new Date(block.updateAt))
break
default:
displayValue = property ? property.value : undefined
displayValue = propertyValue
}
return displayValue
@ -103,13 +103,13 @@ class OctoUtils {
}
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 displayValue = OctoUtils.propertyDisplayValue(card, property, propertyTemplate)
const propertyValue = card.properties[propertyTemplate.id]
const displayValue = OctoUtils.propertyDisplayValue(card, propertyValue, propertyTemplate)
const finalDisplayValue = displayValue || emptyDisplayValue
let propertyColorCssClassName: string
if (property && propertyTemplate.type === "select") {
const cardPropertyValue = propertyTemplate.options.find(o => o.value === property.value)
if (propertyValue && propertyTemplate.type === "select") {
const cardPropertyValue = propertyTemplate.options.find(o => o.value === propertyValue)
if (cardPropertyValue) {
propertyColorCssClassName = cardPropertyValue.color
}
@ -143,7 +143,7 @@ class OctoUtils {
showMenu(e.target as HTMLElement)
}
} : undefined}
onFocus={mutator ? () => { Menu.shared.hide() } : undefined }
onFocus={mutator ? () => { Menu.shared.hide() } : undefined}
>
{finalDisplayValue}
</div>
@ -176,7 +176,7 @@ class OctoUtils {
if (index === 0) {
return block.order / 2
}
const previousBlock = blocks[index-1]
const previousBlock = blocks[index - 1]
return (block.order + previousBlock.order) / 2
}
@ -185,9 +185,40 @@ class OctoUtils {
if (index === blocks.length - 1) {
return block.order + 1000
}
const nextBlock = blocks[index+1]
const nextBlock = blocks[index + 1]
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 }

View 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 }

View file

@ -6,7 +6,7 @@
}
.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%;
min-width: 24px;
min-height: 24px;
@ -19,9 +19,32 @@
min-height: 24px;
}
/*-- Menu images --*/
.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-size: 100% 100%;
min-width: 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;
}

View file

@ -5,6 +5,10 @@
}
html, body {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
color: rgb(55, 53, 47);
}
@ -36,7 +40,7 @@ hr {
margin-bottom: 8px;
}
header {
.page-header {
font-size: 18px;
font-weight: bold;
background-color: #eeeeee;
@ -44,16 +48,12 @@ header {
padding: 10px 20px;
}
header a {
.page-header a {
color: #505050;
}
main {
padding: 10px 20px;
}
footer {
padding: 10px 20px;
.page-loading {
margin: 50px auto;
}
.title, h1 {
@ -64,19 +64,88 @@ footer {
display: inline-block;
}
/* OCTO */
/* App frame */
#octo-tasks-app {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: auto;
}
#octo-tasks-app > #main {
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 {
padding: 10px 50px 50px 50px;
min-width: 1000px;
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: scroll;
padding: 10px 95px 50px 95px;
}
.octo-board {
flex: 0 1 auto;
display: flex;
flex-direction: column;
}
.octo-controls {
flex: 0 0 auto;
display: flex;
flex-direction: row;
@ -119,12 +188,14 @@ footer {
display: flex;
flex-direction: row;
padding: 0 10px;
flex: 0 1 auto;
}
.octo-board-column {
flex: 0 0 auto;
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 260px;
margin-right: 15px;
@ -140,6 +211,7 @@ footer {
}
.octo-board-card {
flex: 0 0 auto;
position: relative;
display: flex;
flex-direction: column;
@ -357,6 +429,7 @@ footer {
}
.octo-icontitle {
flex: 0 0 auto;
display: flex;
flex-direction: row;
align-items: center;
@ -369,6 +442,7 @@ footer {
}
.octo-board-card > .octo-icontitle {
flex: 1 1 auto;
font-weight: 500;
}
@ -535,6 +609,7 @@ footer {
}
.octo-table-cell {
flex: 0 0 auto;
display: flex;
flex-direction: row;
@ -543,7 +618,7 @@ footer {
box-sizing: border-box;
padding: 5px 8px 6px 8px;
width: 240px;
width: 150px;
min-height: 32px;
font-size: 14px;
@ -553,6 +628,10 @@ footer {
position: relative;
}
.octo-table-cell.title-cell {
width: 280px;
}
.octo-table-cell .octo-propertyvalue {
line-height: 17px;
}

View file

@ -1,9 +1,9 @@
const webpack = require("webpack");
const path = require("path");
const CopyPlugin = require("copy-webpack-plugin");
var HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require("webpack")
const path = require("path")
const CopyPlugin = require("copy-webpack-plugin")
var HtmlWebpackPlugin = require('html-webpack-plugin')
const outpath = path.resolve(__dirname, "pack");
const outpath = path.resolve(__dirname, "pack")
function makeCommonConfig() {
const commonConfig = {
@ -55,20 +55,6 @@ function makeCommonConfig() {
{ from: path.resolve(__dirname, "node_modules/easymde/dist/easymde.min.css"), to: "static" },
],
}),
new HtmlWebpackPlugin({
inject: true,
title: "OCTO",
chunks: [],
template: "html-templates/index.ejs",
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",
@ -78,16 +64,15 @@ function makeCommonConfig() {
}),
],
entry: {
boardsPage: "./src/client/boardsPage.ts",
boardPage: "./src/client/boardPage.tsx"
},
output: {
filename: "[name].js",
path: outpath
}
};
}
return commonConfig;
return commonConfig
}
module.exports = makeCommonConfig;
module.exports = makeCommonConfig