Merge branch 'main' into split-dialog-and-card-detail

This commit is contained in:
Chen-I Lim 2020-10-18 19:35:09 -07:00
commit 83bc16184a
72 changed files with 4573 additions and 905 deletions

29
.editorconfig Normal file
View file

@ -0,0 +1,29 @@
# http://editorconfig.org/
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.go]
indent_style = tab
[*.{js,jsx,json,html}]
indent_style = space
indent_size = 4
[{package.json,.eslintrc.json}]
indent_size = 2
[i18n/**.json]
indent_size = 2
[Makefile]
indent_style = tab
[*.scss]
indent_style = space
indent_size = 4

196
.eslintrc.json Normal file
View file

@ -0,0 +1,196 @@
{
"extends": [
"plugin:mattermost/react",
"plugin:cypress/recommended",
"plugin:jquery/deprecated"
],
"plugins": [
"babel",
"mattermost",
"import",
"cypress",
"jquery",
"no-only-tests"
],
"parser": "@typescript-eslint/parser",
"env": {
"jest": true,
"cypress/globals": true
},
"settings": {
"import/resolver": "webpack",
"react": {
"pragma": "React",
"version": "detect"
}
},
"rules": {
"no-unused-expressions": 0,
"babel/no-unused-expressions": 2,
"eol-last": ["error", "always"],
"import/no-unresolved": 2,
"import/order": [
2,
{
"newlines-between": "always-and-inside-groups",
"groups": [
"builtin",
"external",
[
"internal",
"parent"
],
"sibling",
"index"
]
}
],
"no-undefined": 0,
"react/jsx-filename-extension": 0,
"react/prop-types": [
2,
{
"ignore": [
"location",
"history",
"component"
]
}
],
"react/no-string-refs": 2,
"no-only-tests/no-only-tests": ["error", {"focus": ["only", "skip"]}]
},
"overrides": [
{
"files": ["**/*.tsx", "**/*.ts"],
"extends": [
"plugin:@typescript-eslint/recommended"
],
"rules": {
"import/no-unresolved": 0, // ts handles this better
"camelcase": 0,
"@typescript-eslint/naming-convention": [
2,
{
"selector": "function",
"format": ["camelCase", "PascalCase"]
},
{
"selector": "variable",
"format": ["camelCase", "PascalCase", "UPPER_CASE"]
},
{
"selector": "parameter",
"format": ["camelCase", "PascalCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
}
],
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/no-unused-vars": [
2,
{
"vars": "all",
"args": "after-used"
}
],
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/no-empty-function": 0,
"@typescript-eslint/prefer-interface": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/indent": [
2,
4,
{
"SwitchCase": 0
}
],
"@typescript-eslint/no-use-before-define": [
2,
{
"classes": false,
"functions": false,
"variables": false
}
],
"react/jsx-filename-extension": 0
}
},
{
"files": ["tests/**", "**/*.test.*"],
"env": {
"jest": true
},
"rules": {
"func-names": 0,
"global-require": 0,
"new-cap": 0,
"prefer-arrow-callback": 0,
"no-import-assign": 0
}
},
{
"files": ["e2e/**"],
"rules": {
"func-names": 0,
"import/no-unresolved": 0,
"max-nested-callbacks": 0,
"no-process-env": 0,
"babel/no-unused-expressions": 0,
"no-unused-expressions": 0,
"jquery/no-ajax": 0,
"jquery/no-ajax-events": 0,
"jquery/no-animate": 0,
"jquery/no-attr": 0,
"jquery/no-bind": 0,
"jquery/no-class": 0,
"jquery/no-clone": 0,
"jquery/no-closest": 0,
"jquery/no-css": 0,
"jquery/no-data": 0,
"jquery/no-deferred": 0,
"jquery/no-delegate": 0,
"jquery/no-each": 0,
"jquery/no-extend": 0,
"jquery/no-fade": 0,
"jquery/no-filter": 0,
"jquery/no-find": 0,
"jquery/no-global-eval": 0,
"jquery/no-grep": 0,
"jquery/no-has": 0,
"jquery/no-hide": 0,
"jquery/no-html": 0,
"jquery/no-in-array": 0,
"jquery/no-is-array": 0,
"jquery/no-is-function": 0,
"jquery/no-is": 0,
"jquery/no-load": 0,
"jquery/no-map": 0,
"jquery/no-merge": 0,
"jquery/no-param": 0,
"jquery/no-parent": 0,
"jquery/no-parents": 0,
"jquery/no-parse-html": 0,
"jquery/no-prop": 0,
"jquery/no-proxy": 0,
"jquery/no-ready": 0,
"jquery/no-serialize": 0,
"jquery/no-show": 0,
"jquery/no-size": 0,
"jquery/no-sizzle": 0,
"jquery/no-slide": 0,
"jquery/no-submit": 0,
"jquery/no-text": 0,
"jquery/no-toggle": 0,
"jquery/no-trigger": 0,
"jquery/no-trim": 0,
"jquery/no-val": 0,
"jquery/no-when": 0,
"jquery/no-wrap": 0
}
}
]
}

1
.gitignore vendored
View file

@ -36,3 +36,4 @@ debug
__debug_bin
files
octo*.db
.eslintcache

View file

@ -2,9 +2,6 @@
all: build
GOMAIN = ./server/main
GOBIN = ./bin/octoserver
pack:
npm run pack
@ -12,10 +9,16 @@ packdev:
npm run packdev
go:
go build -o $(GOBIN) $(GOMAIN)
cd server; go build -o ../bin/octoserver ./main
generate:
cd server; go generate ./...
watch-server:
cd server; modd
goUbuntu:
env GOOS=linux GOARCH=amd64 go build -o $(GOBIN) $(GOMAIN)
cd server; env GOOS=linux GOARCH=amd64 go build -o ../bin/octoserver ./main
builddev: packdev go

View file

@ -5,10 +5,10 @@
<meta charset="UTF-8">
<title><%= htmlWebpackPlugin.options.title %></title>
<link rel="stylesheet" href="/easymde.min.css">
<link rel="stylesheet" href="/main.css">
<link rel="stylesheet" href="/images.css">
<link rel="stylesheet" href="/colors.css">
<link rel="stylesheet" href="/static/easymde.min.css">
<link rel="stylesheet" href="/static/main.css">
<link rel="stylesheet" href="/static/images.css">
<link rel="stylesheet" href="/static/colors.css">
</head>
<body>

1272
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,9 @@
"pack": "NODE_ENV=production webpack --config webpack.js",
"packdev": "NODE_ENV=dev webpack --config webpack.dev.js",
"watchdev": "NODE_ENV=dev webpack --watch --config webpack.dev.js",
"test": "jest"
"test": "jest",
"check": "eslint --ext .tsx,.ts . --quiet --cache",
"fix": "eslint --ext .tsx,.ts . --quiet --fix --cache"
},
"dependencies": {
"marked": "^1.1.1",
@ -29,8 +31,20 @@
"@types/react": "^16.9.49",
"@types/react-dom": "^16.9.8",
"@types/react-router-dom": "^5.1.6",
"@typescript-eslint/eslint-plugin": "^4.4.1",
"@typescript-eslint/parser": "^4.4.1",
"copy-webpack-plugin": "^6.0.3",
"css-loader": "^4.3.0",
"eslint": "^7.11.0",
"eslint-import-resolver-webpack": "0.12.2",
"eslint-plugin-babel": "5.3.1",
"eslint-plugin-cypress": "2.11.1",
"eslint-plugin-header": "3.0.0",
"eslint-plugin-import": "2.22.0",
"eslint-plugin-jquery": "1.5.1",
"eslint-plugin-mattermost": "github:mattermost/eslint-plugin-mattermost#070ce792d105482ffb2b27cfc0b7e78b3d20acee",
"eslint-plugin-no-only-tests": "2.4.0",
"eslint-plugin-react": "7.20.6",
"file-loader": "^6.1.0",
"html-webpack-plugin": "^4.5.0",
"jest": "^26.5.3",

275
server/api/api.go Normal file
View file

@ -0,0 +1,275 @@
package api
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"path/filepath"
"strings"
"github.com/gorilla/mux"
"github.com/mattermost/mattermost-octo-tasks/server/app"
"github.com/mattermost/mattermost-octo-tasks/server/model"
)
// ----------------------------------------------------------------------------------------------------
// REST APIs
type API struct {
appBuilder func() *app.App
}
func NewAPI(appBuilder func() *app.App) *API {
return &API{appBuilder: appBuilder}
}
func (a *API) app() *app.App {
return a.appBuilder()
}
func (a *API) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/blocks", a.handleGetBlocks).Methods("GET")
r.HandleFunc("/api/v1/blocks", a.handlePostBlocks).Methods("POST")
r.HandleFunc("/api/v1/blocks/{blockID}", a.handleDeleteBlock).Methods("DELETE")
r.HandleFunc("/api/v1/blocks/{blockID}/subtree", a.handleGetSubTree).Methods("GET")
r.HandleFunc("/api/v1/files", a.handleUploadFile).Methods("POST")
r.HandleFunc("/files/{filename}", a.handleServeFile).Methods("GET")
r.HandleFunc("/api/v1/blocks/export", a.handleExport).Methods("GET")
r.HandleFunc("/api/v1/blocks/import", a.handleImport).Methods("POST")
}
func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
parentID := query.Get("parent_id")
blockType := query.Get("type")
blocks, err := a.app().GetBlocks(parentID, blockType)
if err != nil {
log.Printf(`ERROR GetBlocks: %v`, r)
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
log.Printf("GetBlocks parentID: %s, type: %s, %d result(s)", parentID, blockType, len(blocks))
json, err := json.Marshal(blocks)
if err != nil {
log.Printf(`ERROR json.Marshal: %v`, r)
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
jsonBytesResponse(w, http.StatusOK, json)
}
func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
requestBody, err := ioutil.ReadAll(r.Body)
if err != nil {
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
// Catch panics from parse errors, etc.
defer func() {
if r := recover(); r != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
}()
var blocks []model.Block
err = json.Unmarshal([]byte(requestBody), &blocks)
if err != nil {
errorResponse(w, http.StatusInternalServerError, ``)
return
}
for _, block := range blocks {
// Error checking
if len(block.Type) < 1 {
errorResponse(w, http.StatusInternalServerError, fmt.Sprintf(`{"description": "missing type", "id": "%s"}`, block.ID))
return
}
if block.CreateAt < 1 {
errorResponse(w, http.StatusInternalServerError, fmt.Sprintf(`{"description": "invalid createAt", "id": "%s"}`, block.ID))
return
}
if block.UpdateAt < 1 {
errorResponse(w, http.StatusInternalServerError, fmt.Sprintf(`{"description": "invalid updateAt", "id": "%s"}`, block.ID))
return
}
}
err = a.app().InsertBlocks(blocks)
if err != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
log.Printf("POST Blocks %d block(s)", len(blocks))
jsonStringResponse(w, http.StatusOK, "{}")
}
func (a *API) handleDeleteBlock(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
blockID := vars["blockID"]
err := a.app().DeleteBlock(blockID)
if err != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
log.Printf("DELETE Block %s", blockID)
jsonStringResponse(w, http.StatusOK, "{}")
}
func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
blockID := vars["blockID"]
blocks, err := a.app().GetSubTree(blockID)
if err != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
log.Printf("GetSubTree blockID: %s, %d result(s)", blockID, len(blocks))
json, err := json.Marshal(blocks)
if err != nil {
log.Printf(`ERROR json.Marshal: %v`, r)
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
jsonBytesResponse(w, http.StatusOK, json)
}
func (a *API) handleExport(w http.ResponseWriter, r *http.Request) {
blocks, err := a.app().GetAllBlocks()
if err != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
log.Printf("EXPORT Blocks, %d result(s)", len(blocks))
json, err := json.Marshal(blocks)
if err != nil {
log.Printf(`ERROR json.Marshal: %v`, r)
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
jsonBytesResponse(w, http.StatusOK, json)
}
func (a *API) handleImport(w http.ResponseWriter, r *http.Request) {
requestBody, err := ioutil.ReadAll(r.Body)
if err != nil {
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
// Catch panics from parse errors, etc.
defer func() {
if r := recover(); r != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
}()
var blocks []model.Block
err = json.Unmarshal([]byte(requestBody), &blocks)
if err != nil {
errorResponse(w, http.StatusInternalServerError, ``)
return
}
for _, block := range blocks {
err := a.app().InsertBlock(block)
if err != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
}
log.Printf("IMPORT Blocks %d block(s)", len(blocks))
jsonStringResponse(w, http.StatusOK, "{}")
}
// File upload
func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
filename := vars["filename"]
contentType := "image/jpg"
fileExtension := strings.ToLower(filepath.Ext(filename))
if fileExtension == "png" {
contentType = "image/png"
}
w.Header().Set("Content-Type", contentType)
filePath := a.app().GetFilePath(filename)
http.ServeFile(w, r, filePath)
}
func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
fmt.Println(`handleUploadFile`)
file, handle, err := r.FormFile("file")
if err != nil {
fmt.Fprintf(w, "%v", err)
return
}
defer file.Close()
log.Printf(`handleUploadFile, filename: %s`, handle.Filename)
url, err := a.app().SaveFile(file, handle.Filename)
if err != nil {
jsonStringResponse(w, http.StatusInternalServerError, `{}`)
return
}
log.Printf(`saveFile, url: %s`, url)
json := fmt.Sprintf(`{ "url": "%s" }`, url)
jsonStringResponse(w, http.StatusOK, json)
}
// Response helpers
func jsonStringResponse(w http.ResponseWriter, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
fmt.Fprint(w, message)
}
func jsonBytesResponse(w http.ResponseWriter, code int, json []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(json)
}
func errorResponse(w http.ResponseWriter, code int, message string) {
log.Printf("%d ERROR", code)
w.WriteHeader(code)
fmt.Fprint(w, message)
}
func addUserID(rw http.ResponseWriter, req *http.Request, next http.Handler) {
ctx := context.WithValue(req.Context(), "userid", req.Header.Get("userid"))
req = req.WithContext(ctx)
next.ServeHTTP(rw, req)
}

130
server/app/app.go Normal file
View file

@ -0,0 +1,130 @@
package app
import (
"crypto/rand"
"errors"
"fmt"
"io"
"log"
"path/filepath"
"strings"
"github.com/mattermost/mattermost-octo-tasks/server/model"
"github.com/mattermost/mattermost-octo-tasks/server/services/config"
"github.com/mattermost/mattermost-octo-tasks/server/services/store"
"github.com/mattermost/mattermost-octo-tasks/server/ws"
"github.com/mattermost/mattermost-server/v5/services/filesstore"
)
type App struct {
config *config.Configuration
store store.Store
wsServer *ws.WSServer
filesBackend filesstore.FileBackend
}
func New(config *config.Configuration, store store.Store, wsServer *ws.WSServer, filesBackend filesstore.FileBackend) *App {
return &App{config: config, store: store, wsServer: wsServer, filesBackend: filesBackend}
}
func (a *App) GetBlocks(parentID string, blockType string) ([]model.Block, error) {
if len(blockType) > 0 && len(parentID) > 0 {
return a.store.GetBlocksWithParentAndType(parentID, blockType)
}
if len(blockType) > 0 {
return a.store.GetBlocksWithType(blockType)
}
return a.store.GetBlocksWithParent(parentID)
}
func (a *App) GetParentID(blockID string) (string, error) {
return a.store.GetParentID(blockID)
}
func (a *App) InsertBlock(block model.Block) error {
return a.store.InsertBlock(block)
}
func (a *App) InsertBlocks(blocks []model.Block) error {
var blockIDsToNotify = []string{}
uniqueBlockIDs := make(map[string]bool)
for _, block := range blocks {
if !uniqueBlockIDs[block.ID] {
blockIDsToNotify = append(blockIDsToNotify, block.ID)
}
if len(block.ParentID) > 0 && !uniqueBlockIDs[block.ParentID] {
blockIDsToNotify = append(blockIDsToNotify, block.ParentID)
}
err := a.store.InsertBlock(block)
if err != nil {
return err
}
}
a.wsServer.BroadcastBlockChangeToWebsocketClients(blockIDsToNotify)
return nil
}
func (a *App) GetSubTree(blockID string) ([]model.Block, error) {
return a.store.GetSubTree(blockID)
}
func (a *App) GetAllBlocks() ([]model.Block, error) {
return a.store.GetAllBlocks()
}
func (a *App) DeleteBlock(blockID string) error {
var blockIDsToNotify = []string{blockID}
parentID, err := a.GetParentID(blockID)
if err != nil {
return err
}
if len(parentID) > 0 {
blockIDsToNotify = append(blockIDsToNotify, parentID)
}
err = a.store.DeleteBlock(blockID)
if err != nil {
return err
}
a.wsServer.BroadcastBlockChangeToWebsocketClients(blockIDsToNotify)
return nil
}
func (a *App) SaveFile(reader io.Reader, filename string) (string, error) {
// NOTE: File extension includes the dot
fileExtension := strings.ToLower(filepath.Ext(filename))
if fileExtension == ".jpeg" {
fileExtension = ".jpg"
}
createdFilename := fmt.Sprintf(`%s%s`, createGUID(), fileExtension)
_, appErr := a.filesBackend.WriteFile(reader, createdFilename)
if appErr != nil {
return "", errors.New("unable to store the file in the files storage")
}
return fmt.Sprintf(`%s/files/%s`, a.config.ServerRoot, createdFilename), nil
}
func (a *App) GetFilePath(filename string) string {
folderPath := a.config.FilesPath
return filepath.Join(folderPath, filename)
}
// CreateGUID returns a random GUID
func createGUID() string {
b := make([]byte, 16)
_, err := rand.Read(b)
if err != nil {
log.Fatal(err)
}
uuid := fmt.Sprintf("%x-%x-%x-%x-%x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
return uuid
}

20
server/go.mod Normal file
View file

@ -0,0 +1,20 @@
module github.com/mattermost/mattermost-octo-tasks/server
go 1.15
require (
github.com/golang-migrate/migrate v3.5.4+incompatible
github.com/golang-migrate/migrate/v4 v4.13.0
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.2
github.com/jteeuwen/go-bindata v3.0.7+incompatible // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lib/pq v1.8.0
github.com/mattermost/mattermost-server/v5 v5.28.0
github.com/mattn/go-sqlite3 v2.0.3+incompatible
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/spf13/viper v1.7.1
github.com/stretchr/testify v1.6.1
golang.org/x/tools v0.0.0-20201017001424-6003fad69a88 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
)

1426
server/go.sum Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,289 +1,17 @@
package main
import (
"encoding/json"
"flag"
"io/ioutil"
"mime/multipart"
"path/filepath"
"strings"
"syscall"
"time"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"github.com/gorilla/mux"
"github.com/mattermost/mattermost-octo-tasks/server/server"
"github.com/mattermost/mattermost-octo-tasks/server/services/config"
)
var config *Configuration
var wsServer *WSServer
var store *SQLStore
// ----------------------------------------------------------------------------------------------------
// HTTP handlers
func serveWebFile(w http.ResponseWriter, r *http.Request, relativeFilePath string) {
folderPath := config.WebPath
filePath := filepath.Join(folderPath, relativeFilePath)
http.ServeFile(w, r, filePath)
}
func handleStaticFile(r *mux.Router, requestPath string, filePath string, contentType string) {
r.HandleFunc(requestPath, func(w http.ResponseWriter, r *http.Request) {
log.Printf("handleStaticFile: %s", requestPath)
w.Header().Set("Content-Type", contentType)
serveWebFile(w, r, filePath)
})
}
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
func handleGetBlocks(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
parentID := query.Get("parent_id")
blockType := query.Get("type")
var blocks []string
if len(blockType) > 0 && len(parentID) > 0 {
blocks = store.getBlocksWithParentAndType(parentID, blockType)
} else if len(blockType) > 0 {
blocks = store.getBlocksWithType(blockType)
} else {
blocks = store.getBlocksWithParent(parentID)
}
log.Printf("GetBlocks parentID: %s, type: %s, %d result(s)", parentID, blockType, len(blocks))
response := `[` + strings.Join(blocks[:], ",") + `]`
jsonResponse(w, http.StatusOK, response)
}
func handlePostBlocks(w http.ResponseWriter, r *http.Request) {
requestBody, err := ioutil.ReadAll(r.Body)
if err != nil {
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
// Catch panics from parse errors, etc.
defer func() {
if r := recover(); r != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
}()
var blocks []Block
err = json.Unmarshal([]byte(requestBody), &blocks)
if err != nil {
errorResponse(w, http.StatusInternalServerError, ``)
return
}
var blockIDsToNotify = []string{}
uniqueBlockIDs := make(map[string]bool)
for _, block := range blocks {
// Error checking
if len(block.Type) < 1 {
errorResponse(w, http.StatusInternalServerError, fmt.Sprintf(`{"description": "missing type", "id": "%s"}`, block.ID))
return
}
if block.CreateAt < 1 {
errorResponse(w, http.StatusInternalServerError, fmt.Sprintf(`{"description": "invalid createAt", "id": "%s"}`, block.ID))
return
}
if block.UpdateAt < 1 {
errorResponse(w, http.StatusInternalServerError, fmt.Sprintf(`{"description": "invalid updateAt", "id": "%s"}`, block.ID))
return
}
if !uniqueBlockIDs[block.ID] {
blockIDsToNotify = append(blockIDsToNotify, block.ID)
}
if len(block.ParentID) > 0 && !uniqueBlockIDs[block.ParentID] {
blockIDsToNotify = append(blockIDsToNotify, block.ParentID)
}
jsonBytes, err := json.Marshal(block)
if err != nil {
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
store.insertBlock(block, string(jsonBytes))
}
wsServer.broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
log.Printf("POST Blocks %d block(s)", len(blocks))
jsonResponse(w, http.StatusOK, "{}")
}
func handleDeleteBlock(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
blockID := vars["blockID"]
var blockIDsToNotify = []string{blockID}
parentID := store.getParentID(blockID)
if len(parentID) > 0 {
blockIDsToNotify = append(blockIDsToNotify, parentID)
}
store.deleteBlock(blockID)
wsServer.broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
log.Printf("DELETE Block %s", blockID)
jsonResponse(w, http.StatusOK, "{}")
}
func handleGetSubTree(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
blockID := vars["blockID"]
blocks := store.getSubTree(blockID)
log.Printf("GetSubTree blockID: %s, %d result(s)", blockID, len(blocks))
response := `[` + strings.Join(blocks[:], ",") + `]`
jsonResponse(w, http.StatusOK, response)
}
func handleExport(w http.ResponseWriter, r *http.Request) {
blocks := store.getAllBlocks()
log.Printf("EXPORT Blocks, %d result(s)", len(blocks))
response := `[` + strings.Join(blocks[:], ",") + `]`
jsonResponse(w, http.StatusOK, response)
}
func handleImport(w http.ResponseWriter, r *http.Request) {
requestBody, err := ioutil.ReadAll(r.Body)
if err != nil {
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
// Catch panics from parse errors, etc.
defer func() {
if r := recover(); r != nil {
log.Printf(`ERROR: %v`, r)
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
}()
var blocks []Block
err = json.Unmarshal([]byte(requestBody), &blocks)
if err != nil {
errorResponse(w, http.StatusInternalServerError, ``)
return
}
for _, block := range blocks {
jsonBytes, err := json.Marshal(block)
if err != nil {
errorResponse(w, http.StatusInternalServerError, `{}`)
return
}
store.insertBlock(block, string(jsonBytes))
}
log.Printf("IMPORT Blocks %d block(s)", len(blocks))
jsonResponse(w, http.StatusOK, "{}")
}
// File upload
func handleServeFile(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
filename := vars["filename"]
contentType := "image/jpg"
fileExtension := strings.ToLower(filepath.Ext(filename))
if fileExtension == "png" {
contentType = "image/png"
}
w.Header().Set("Content-Type", contentType)
folderPath := config.FilesPath
filePath := filepath.Join(folderPath, filename)
http.ServeFile(w, r, filePath)
}
func handleUploadFile(w http.ResponseWriter, r *http.Request) {
fmt.Println(`handleUploadFile`)
file, handle, err := r.FormFile("file")
if err != nil {
fmt.Fprintf(w, "%v", err)
return
}
defer file.Close()
log.Printf(`handleUploadFile, filename: %s`, handle.Filename)
saveFile(w, file, handle)
}
func saveFile(w http.ResponseWriter, file multipart.File, handle *multipart.FileHeader) {
data, err := ioutil.ReadAll(file)
if err != nil {
fmt.Fprintf(w, "%v", err)
return
}
// NOTE: File extension includes the dot
fileExtension := strings.ToLower(filepath.Ext(handle.Filename))
if fileExtension == ".jpeg" {
fileExtension = ".jpg"
}
filename := fmt.Sprintf(`%s%s`, createGUID(), fileExtension)
folderPath := config.FilesPath
filePath := filepath.Join(folderPath, filename)
os.MkdirAll(folderPath, os.ModePerm)
err = ioutil.WriteFile(filePath, data, 0666)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, `{}`)
return
}
url := fmt.Sprintf(`%s/files/%s`, config.ServerRoot, filename)
log.Printf(`saveFile, url: %s`, url)
json := fmt.Sprintf(`{ "url": "%s" }`, url)
jsonResponse(w, http.StatusOK, json)
}
// Response helpers
func jsonResponse(w http.ResponseWriter, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
fmt.Fprint(w, message)
}
func errorResponse(w http.ResponseWriter, code int, message string) {
log.Printf("%d ERROR", code)
w.WriteHeader(code)
fmt.Fprint(w, message)
}
// ----------------------------------------------------------------------------------------------------
// WebSocket OnChange listener
@ -294,11 +22,7 @@ func isProcessRunning(pid int) bool {
}
err = process.Signal(syscall.Signal(0))
if err != nil {
return false
}
return true
return err == nil
}
func monitorPid(pid int) {
@ -316,8 +40,7 @@ func monitorPid(pid int) {
func main() {
// config.json file
var err error
config, err = readConfigFile()
config, err := config.ReadConfigFile()
if err != nil {
log.Fatal("Unable to read the config file: ", err)
return
@ -338,77 +61,12 @@ func main() {
config.Port = *pPort
}
wsServer = NewWSServer()
r := mux.NewRouter()
// Static files
handleDefault(r, "/")
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, "/favicon.ico", "static/favicon.svg", "image/svg+xml; charset=utf-8")
handleStaticFile(r, "/easymde.min.css", "static/easymde.min.css", "text/css")
handleStaticFile(r, "/main.css", "static/main.css", "text/css")
handleStaticFile(r, "/colors.css", "static/colors.css", "text/css")
handleStaticFile(r, "/images.css", "static/images.css", "text/css")
// APIs
r.HandleFunc("/api/v1/blocks", handleGetBlocks).Methods("GET")
r.HandleFunc("/api/v1/blocks", handlePostBlocks).Methods("POST")
r.HandleFunc("/api/v1/blocks/{blockID}", handleDeleteBlock).Methods("DELETE")
r.HandleFunc("/api/v1/blocks/{blockID}/subtree", handleGetSubTree).Methods("GET")
r.HandleFunc("/api/v1/files", handleUploadFile).Methods("POST")
r.HandleFunc("/api/v1/blocks/export", handleExport).Methods("GET")
r.HandleFunc("/api/v1/blocks/import", handleImport).Methods("POST")
// WebSocket
r.HandleFunc("/ws/onchange", wsServer.handleWebSocketOnChange)
// Files
r.HandleFunc("/files/{filename}", handleServeFile).Methods("GET")
http.Handle("/", r)
store, err = NewSQLStore(config.DBType, config.DBConfigString)
server, err := server.New(config)
if err != nil {
log.Fatal("Unable to start the database", err)
panic(err)
log.Fatal("ListenAndServeTLS: ", err)
}
// Ctrl+C handling
handler := make(chan os.Signal, 1)
signal.Notify(handler, os.Interrupt)
go func() {
for sig := range handler {
// sig is a ^C, handle it
if sig == os.Interrupt {
os.Exit(1)
break
}
}
}()
// Start the server, with SSL if the certs exist
urlPort := fmt.Sprintf(`:%d`, config.Port)
var isSSL = config.UseSSL && fileExists("./cert/cert.pem") && fileExists("./cert/key.pem")
if isSSL {
log.Println("https server started on ", urlPort)
err := http.ListenAndServeTLS(urlPort, "./cert/cert.pem", "./cert/key.pem", nil)
if err != nil {
log.Fatal("ListenAndServeTLS: ", err)
}
} else {
log.Println("http server started on ", urlPort)
err := http.ListenAndServe(urlPort, nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
if err := server.Start(); err != nil {
log.Fatal("ListenAndServeTLS: ", err)
}
}

View file

@ -1,300 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
"time"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
)
// SQLStore is a SQL database
type SQLStore struct {
db *sql.DB
dbType string
}
// NewSQLStore creates a new SQLStore
func NewSQLStore(dbType, connectionString string) (*SQLStore, error) {
log.Println("connectDatabase")
var err error
db, err := sql.Open(dbType, connectionString)
if err != nil {
log.Fatal("connectDatabase: ", err)
return nil, err
}
err = db.Ping()
if err != nil {
log.Println(`Database Ping failed`)
return nil, err
}
store := &SQLStore{
db: db,
dbType: dbType,
}
err = store.createTablesIfNotExists()
if err != nil {
log.Println(`Table creation failed`)
return nil, err
}
return store, nil
}
// Block is the basic data unit
type Block struct {
ID string `json:"id"`
ParentID string `json:"parentId"`
Schema int64 `json:"schema"`
Type string `json:"type"`
Title string `json:"title"`
Order int64 `json:"order"`
Fields map[string]interface{} `json:"fields"`
CreateAt int64 `json:"createAt"`
UpdateAt int64 `json:"updateAt"`
DeleteAt int64 `json:"deleteAt"`
}
func (s *SQLStore) createTablesIfNotExists() error {
// TODO: Add update_by with the user's ID
// TODO: Consolidate insert_at and update_at, decide if the server of DB should set it
var query string
if s.dbType == "sqlite3" {
query = `CREATE TABLE IF NOT EXISTS blocks (
id VARCHAR(36),
insert_at DATETIME NOT NULL DEFAULT current_timestamp,
parent_id VARCHAR(36),
type TEXT,
json TEXT,
create_at BIGINT,
update_at BIGINT,
delete_at BIGINT,
PRIMARY KEY (id, insert_at)
);`
} else {
query = `CREATE TABLE IF NOT EXISTS blocks (
id VARCHAR(36),
insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
parent_id VARCHAR(36),
type TEXT,
json TEXT,
create_at BIGINT,
update_at BIGINT,
delete_at BIGINT,
PRIMARY KEY (id, insert_at)
);`
}
_, err := s.db.Exec(query)
if err != nil {
log.Fatal("createTablesIfNotExists: ", err)
return err
}
log.Printf("createTablesIfNotExists(%s)", s.dbType)
return nil
}
func (s *SQLStore) getBlocksWithParentAndType(parentID string, 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 parent_id = $1 and type = $2`
rows, err := s.db.Query(query, parentID, blockType)
if err != nil {
log.Printf(`getBlocksWithParentAndType ERROR: %v`, err)
panic(err)
}
return blocksFromRows(rows)
}
func (s *SQLStore) getBlocksWithParent(parentID 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 parent_id = $1`
rows, err := s.db.Query(query, parentID)
if err != nil {
log.Printf(`getBlocksWithParent ERROR: %v`, err)
panic(err)
}
return blocksFromRows(rows)
}
func (s *SQLStore) 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 := s.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 {
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 (id = $1
OR parent_id = $1)`
rows, err := s.db.Query(query, blockID)
if err != nil {
log.Printf(`getSubTree ERROR: %v`, err)
panic(err)
}
return blocksFromRows(rows)
}
func (s *SQLStore) getAllBlocks() []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`
rows, err := s.db.Query(query)
if err != nil {
log.Printf(`getAllBlocks ERROR: %v`, err)
panic(err)
}
return blocksFromRows(rows)
}
func blocksFromRows(rows *sql.Rows) []string {
defer rows.Close()
var results []string
for rows.Next() {
var json string
err := rows.Scan(&json)
if err != nil {
// handle this error
log.Printf(`blocksFromRows ERROR: %v`, err)
panic(err)
}
results = append(results, json)
}
return results
}
func (s *SQLStore) getParentID(blockID string) string {
statement :=
`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 parent_id
FROM latest
WHERE delete_at = 0
AND id = $1`
row := s.db.QueryRow(statement, blockID)
var parentID string
err := row.Scan(&parentID)
if err != nil {
return ""
}
return parentID
}
func (s *SQLStore) insertBlock(block Block, json string) {
statement := `INSERT INTO blocks(id, parent_id, type, json, create_at, update_at, delete_at) VALUES($1, $2, $3, $4, $5, $6, $7)`
_, err := s.db.Exec(statement, block.ID, block.ParentID, block.Type, json, block.CreateAt, block.UpdateAt, block.DeleteAt)
if err != nil {
panic(err)
}
}
func (s *SQLStore) deleteBlock(blockID string) {
now := time.Now().Unix()
json := fmt.Sprintf(`{"id":"%s","updateAt":%d,"deleteAt":%d}`, blockID, now, now)
statement := `INSERT INTO blocks(id, json, update_at, delete_at) VALUES($1, $2, $3, $4)`
_, err := s.db.Exec(statement, blockID, json, now, now)
if err != nil {
panic(err)
}
}

View file

@ -1,30 +0,0 @@
package main
import (
"crypto/rand"
"fmt"
"log"
"os"
)
// FileExists returns true if a file exists at the path
func fileExists(path string) bool {
_, err := os.Stat(path)
if os.IsNotExist(err) {
return false
}
return err == nil
}
// CreateGUID returns a random GUID
func createGUID() string {
b := make([]byte, 16)
_, err := rand.Read(b)
if err != nil {
log.Fatal(err)
}
uuid := fmt.Sprintf("%x-%x-%x-%x-%x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
return uuid
}

4
server/modd.conf Normal file
View file

@ -0,0 +1,4 @@
**/*.go !**/*_test.go {
prep: go build -o ../bin/octoserver ./main
daemon +sigterm: cd .. && ./bin/octoserver
}

14
server/model/block.go Normal file
View file

@ -0,0 +1,14 @@
package model
// Block is the basic data unit
type Block struct {
ID string `json:"id"`
ParentID string `json:"parentId"`
Schema int64 `json:"schema"`
Type string `json:"type"`
Title string `json:"title"`
Fields map[string]interface{} `json:"fields"`
CreateAt int64 `json:"createAt"`
UpdateAt int64 `json:"updateAt"`
DeleteAt int64 `json:"deleteAt"`
}

84
server/server/server.go Normal file
View file

@ -0,0 +1,84 @@
package server
import (
"errors"
"log"
"os"
"os/signal"
"github.com/mattermost/mattermost-octo-tasks/server/api"
"github.com/mattermost/mattermost-octo-tasks/server/app"
"github.com/mattermost/mattermost-octo-tasks/server/services/config"
"github.com/mattermost/mattermost-octo-tasks/server/services/store"
"github.com/mattermost/mattermost-octo-tasks/server/services/store/sqlstore"
"github.com/mattermost/mattermost-octo-tasks/server/web"
"github.com/mattermost/mattermost-octo-tasks/server/ws"
"github.com/mattermost/mattermost-server/v5/model"
"github.com/mattermost/mattermost-server/v5/services/filesstore"
)
type Server struct {
config *config.Configuration
wsServer *ws.WSServer
webServer *web.WebServer
store store.Store
filesBackend filesstore.FileBackend
}
func New(config *config.Configuration) (*Server, error) {
store, err := sqlstore.New(config.DBType, config.DBConfigString)
if err != nil {
log.Fatal("Unable to start the database", err)
return nil, err
}
wsServer := ws.NewWSServer()
filesBackendSettings := model.FileSettings{}
filesBackendSettings.SetDefaults(false)
filesBackendSettings.Directory = &config.FilesPath
filesBackend, appErr := filesstore.NewFileBackend(&filesBackendSettings, false)
if appErr != nil {
log.Fatal("Unable to initialize the files storage")
return nil, errors.New("unable to initialize the files storage")
}
appBuilder := func() *app.App { return app.New(config, store, wsServer, filesBackend) }
webServer := web.NewWebServer(config.WebPath, config.Port, config.UseSSL)
api := api.NewAPI(appBuilder)
webServer.AddRoutes(wsServer)
webServer.AddRoutes(api)
// Ctrl+C handling
handler := make(chan os.Signal, 1)
signal.Notify(handler, os.Interrupt)
go func() {
for sig := range handler {
// sig is a ^C, handle it
if sig == os.Interrupt {
os.Exit(1)
break
}
}
}()
return &Server{
config: config,
wsServer: wsServer,
webServer: webServer,
store: store,
filesBackend: filesBackend,
}, nil
}
func (s *Server) Start() error {
if err := s.webServer.Start(); err != nil {
return err
}
return nil
}
func (s *Server) Shutdown() error {
return s.store.Shutdown()
}

View file

@ -1,4 +1,4 @@
package main
package config
import (
"log"
@ -17,7 +17,7 @@ type Configuration struct {
FilesPath string `json:"filespath" mapstructure:"filespath"`
}
func readConfigFile() (*Configuration, error) {
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

View file

@ -0,0 +1,256 @@
package sqlstore
import (
"database/sql"
"encoding/json"
"log"
"time"
_ "github.com/lib/pq"
"github.com/mattermost/mattermost-octo-tasks/server/model"
_ "github.com/mattn/go-sqlite3"
)
func (s *SQLStore) GetBlocksWithParentAndType(parentID string, blockType string) ([]model.Block, error) {
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 id, parent_id, schema, type, title, COALESCE("fields", '{}'), create_at, update_at, delete_at
FROM latest
WHERE delete_at = 0 and parent_id = $1 and type = $2`
rows, err := s.db.Query(query, parentID, blockType)
if err != nil {
log.Printf(`getBlocksWithParentAndType ERROR: %v`, err)
return nil, err
}
return blocksFromRows(rows)
}
func (s *SQLStore) GetBlocksWithParent(parentID string) ([]model.Block, error) {
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 id, parent_id, schema, type, title, COALESCE("fields", '{}'), create_at, update_at, delete_at
FROM latest
WHERE delete_at = 0 and parent_id = $1`
rows, err := s.db.Query(query, parentID)
if err != nil {
log.Printf(`getBlocksWithParent ERROR: %v`, err)
return nil, err
}
return blocksFromRows(rows)
}
func (s *SQLStore) GetBlocksWithType(blockType string) ([]model.Block, error) {
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 id, parent_id, schema, type, title, COALESCE("fields", '{}'), create_at, update_at, delete_at
FROM latest
WHERE delete_at = 0 and type = $1`
rows, err := s.db.Query(query, blockType)
if err != nil {
log.Printf(`getBlocksWithParentAndType ERROR: %v`, err)
return nil, err
}
return blocksFromRows(rows)
}
func (s *SQLStore) GetSubTree(blockID string) ([]model.Block, error) {
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 id, parent_id, schema, type, title, COALESCE("fields", '{}'), create_at, update_at, delete_at
FROM latest
WHERE delete_at = 0
AND (id = $1
OR parent_id = $1)`
rows, err := s.db.Query(query, blockID)
if err != nil {
log.Printf(`getSubTree ERROR: %v`, err)
return nil, err
}
return blocksFromRows(rows)
}
func (s *SQLStore) GetAllBlocks() ([]model.Block, error) {
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 id, parent_id, schema, type, title, COALESCE("fields", '{}'), create_at, update_at, delete_at
FROM latest
WHERE delete_at = 0`
rows, err := s.db.Query(query)
if err != nil {
log.Printf(`getAllBlocks ERROR: %v`, err)
return nil, err
}
return blocksFromRows(rows)
}
func blocksFromRows(rows *sql.Rows) ([]model.Block, error) {
defer rows.Close()
var results []model.Block
for rows.Next() {
var block model.Block
var fieldsJSON string
err := rows.Scan(
&block.ID,
&block.ParentID,
&block.Schema,
&block.Type,
&block.Title,
&fieldsJSON,
&block.CreateAt,
&block.UpdateAt,
&block.DeleteAt)
if err != nil {
// handle this error
log.Printf(`ERROR blocksFromRows: %v`, err)
return nil, err
}
err = json.Unmarshal([]byte(fieldsJSON), &block.Fields)
if err != nil {
// handle this error
log.Printf(`ERROR blocksFromRows fields: %v`, err)
return nil, err
}
results = append(results, block)
}
return results, nil
}
func (s *SQLStore) GetParentID(blockID string) (string, error) {
statement :=
`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 parent_id
FROM latest
WHERE delete_at = 0
AND id = $1`
row := s.db.QueryRow(statement, blockID)
var parentID string
err := row.Scan(&parentID)
if err != nil {
return "", err
}
return parentID, nil
}
func (s *SQLStore) InsertBlock(block model.Block) error {
fieldsJSON, err := json.Marshal(block.Fields)
if err != nil {
return err
}
statement := `INSERT INTO blocks(
id,
parent_id,
schema,
type,
title,
fields,
create_at,
update_at,
delete_at
)
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)`
_, err = s.db.Exec(
statement,
block.ID,
block.ParentID,
block.Schema,
block.Type,
block.Title,
fieldsJSON,
block.CreateAt,
block.UpdateAt,
block.DeleteAt)
if err != nil {
return err
}
return nil
}
func (s *SQLStore) DeleteBlock(blockID string) error {
now := time.Now().Unix()
statement := `INSERT INTO blocks(id, update_at, delete_at) VALUES($1, $2, $3)`
_, err := s.db.Exec(statement, blockID, now, now)
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,38 @@
package sqlstore
import (
"testing"
"time"
"github.com/mattermost/mattermost-octo-tasks/server/model"
"github.com/stretchr/testify/require"
)
func TestInsertBlock(t *testing.T) {
store, tearDown := SetupTests(t)
defer tearDown()
blocks, err := store.GetAllBlocks()
require.NoError(t, err)
require.Empty(t, blocks)
block := model.Block{
ID: "id-test",
}
err = store.InsertBlock(block)
require.NoError(t, err)
blocks, err = store.GetAllBlocks()
require.NoError(t, err)
require.Len(t, blocks, 1)
// Wait for not colliding the ID+insert_at key
time.Sleep(1 * time.Millisecond)
err = store.DeleteBlock(block.ID)
require.NoError(t, err)
blocks, err = store.GetAllBlocks()
require.NoError(t, err)
require.Empty(t, blocks)
}

View file

@ -0,0 +1,58 @@
package sqlstore
import (
"fmt"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/file"
bindata "github.com/golang-migrate/migrate/v4/source/go_bindata"
_ "github.com/lib/pq"
pgmigrations "github.com/mattermost/mattermost-octo-tasks/server/services/store/sqlstore/migrations/postgres"
"github.com/mattermost/mattermost-octo-tasks/server/services/store/sqlstore/migrations/sqlite"
)
func (s *SQLStore) Migrate() error {
fmt.Println("HOLA")
var driver database.Driver
var err error
var bresource *bindata.AssetSource
if s.dbType == "sqlite3" {
driver, err = sqlite3.WithInstance(s.db, &sqlite3.Config{})
if err != nil {
return err
}
bresource = bindata.Resource(sqlite.AssetNames(),
func(name string) ([]byte, error) {
return sqlite.Asset(name)
})
}
if s.dbType == "postgres" {
driver, err = postgres.WithInstance(s.db, &postgres.Config{})
if err != nil {
return err
}
bresource = bindata.Resource(pgmigrations.AssetNames(),
func(name string) ([]byte, error) {
return pgmigrations.Asset(name)
})
}
d, err := bindata.WithInstance(bresource)
if err != nil {
return err
}
m, err := migrate.NewWithInstance("go-bindata", d, s.dbType, driver)
if err != nil {
return err
}
err = m.Up()
if err != nil && err != migrate.ErrNoChange {
return err
}
return nil
}

View file

@ -0,0 +1,3 @@
//go:generate go-bindata -prefix postgres_files/ -pkg postgres -o postgres/bindata.go ./postgres_files
//go:generate go-bindata -prefix sqlite_files/ -pkg sqlite -o sqlite/bindata.go ./sqlite_files
package migrations

View file

@ -0,0 +1,115 @@
package postgres
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"strings"
)
func bindata_read(data []byte, name string) ([]byte, error) {
gz, err := gzip.NewReader(bytes.NewBuffer(data))
if err != nil {
return nil, fmt.Errorf("Read %q: %v", name, err)
}
var buf bytes.Buffer
_, err = io.Copy(&buf, gz)
gz.Close()
if err != nil {
return nil, fmt.Errorf("Read %q: %v", name, err)
}
return buf.Bytes(), nil
}
var __000001_init_down_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x09\xf2\x0f\x50\x08\x71\x74\xf2\x71\x55\x48\xca\xc9\x4f\xce\x2e\xb6\xe6\x02\x04\x00\x00\xff\xff\x45\xbe\x01\x0f\x13\x00\x00\x00")
func _000001_init_down_sql() ([]byte, error) {
return bindata_read(
__000001_init_down_sql,
"000001_init.down.sql",
)
}
var __000001_init_up_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x64\x8f\xb1\x4e\xc3\x30\x10\x86\xe7\xf8\x29\x6e\x74\xa4\x6c\x48\x2c\x4c\x6e\xb9\x82\x85\xe3\x56\xce\x15\x5a\x96\xca\xc4\x87\xb0\x30\x25\x8a\xcd\xc0\xdb\xa3\x46\x22\x43\xba\xdd\xff\xe9\xbe\x3b\xfd\x6b\x87\x8a\x10\x48\xad\x0c\x82\xde\x80\xdd\x12\xe0\x41\x77\xd4\xc1\x5b\xfa\xee\x3f\x33\x48\x51\xc5\x00\xcf\xca\xad\x1f\x95\x93\x37\xb7\x75\x23\xaa\x78\xce\x3c\x96\x93\x2f\x40\xba\xc5\x8e\x54\xbb\xa3\xd7\xc9\xb5\x7b\x63\xe0\x1e\x37\x6a\x6f\x08\xec\xf6\x45\x5e\xd6\x07\x3f\xf2\xb9\x9c\xae\xce\xe4\xfe\x83\xbf\x3c\xac\xf4\x83\xb6\xd4\x88\xaa\xfc\x0e\x0c\x84\x87\x69\x8e\x25\xcd\xe1\x3d\x72\x0a\xf9\x3f\xf5\x23\xfb\xc2\x97\xef\xb3\xf9\x33\x84\x25\x0a\x9c\x78\x81\x76\x4e\xb7\xca\x1d\xe1\x09\x8f\x20\x63\x68\x60\xee\x51\x8b\xfa\x4e\xfc\x05\x00\x00\xff\xff\xf7\x74\x9c\xd5\x0c\x01\x00\x00")
func _000001_init_up_sql() ([]byte, error) {
return bindata_read(
__000001_init_up_sql,
"000001_init.up.sql",
)
}
// Asset loads and returns the asset for the given name.
// It returns an error if the asset could not be found or
// could not be loaded.
func Asset(name string) ([]byte, error) {
cannonicalName := strings.Replace(name, "\\", "/", -1)
if f, ok := _bindata[cannonicalName]; ok {
return f()
}
return nil, fmt.Errorf("Asset %s not found", name)
}
// AssetNames returns the names of the assets.
func AssetNames() []string {
names := make([]string, 0, len(_bindata))
for name := range _bindata {
names = append(names, name)
}
return names
}
// _bindata is a table, holding each asset generator, mapped to its name.
var _bindata = map[string]func() ([]byte, error){
"000001_init.down.sql": _000001_init_down_sql,
"000001_init.up.sql": _000001_init_up_sql,
}
// AssetDir returns the file names below a certain
// directory embedded in the file by go-bindata.
// For example if you run go-bindata on data/... and data contains the
// following hierarchy:
// data/
// foo.txt
// img/
// a.png
// b.png
// then AssetDir("data") would return []string{"foo.txt", "img"}
// AssetDir("data/img") would return []string{"a.png", "b.png"}
// AssetDir("foo.txt") and AssetDir("notexist") would return an error
// AssetDir("") will return []string{"data"}.
func AssetDir(name string) ([]string, error) {
node := _bintree
if len(name) != 0 {
cannonicalName := strings.Replace(name, "\\", "/", -1)
pathList := strings.Split(cannonicalName, "/")
for _, p := range pathList {
node = node.Children[p]
if node == nil {
return nil, fmt.Errorf("Asset %s not found", name)
}
}
}
if node.Func != nil {
return nil, fmt.Errorf("Asset %s not found", name)
}
rv := make([]string, 0, len(node.Children))
for name := range node.Children {
rv = append(rv, name)
}
return rv, nil
}
type _bintree_t struct {
Func func() ([]byte, error)
Children map[string]*_bintree_t
}
var _bintree = &_bintree_t{nil, map[string]*_bintree_t{
"000001_init.down.sql": &_bintree_t{_000001_init_down_sql, map[string]*_bintree_t{
}},
"000001_init.up.sql": &_bintree_t{_000001_init_up_sql, map[string]*_bintree_t{
}},
}}

View file

@ -0,0 +1 @@
DROP TABLE blocks;

View file

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS blocks (
id VARCHAR(36),
insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
parent_id VARCHAR(36),
schema BIGINT,
type TEXT,
title TEXT,
fields TEXT,
create_at BIGINT,
update_at BIGINT,
delete_at BIGINT,
PRIMARY KEY (id, insert_at)
);

View file

@ -0,0 +1,115 @@
package sqlite
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"strings"
)
func bindata_read(data []byte, name string) ([]byte, error) {
gz, err := gzip.NewReader(bytes.NewBuffer(data))
if err != nil {
return nil, fmt.Errorf("Read %q: %v", name, err)
}
var buf bytes.Buffer
_, err = io.Copy(&buf, gz)
gz.Close()
if err != nil {
return nil, fmt.Errorf("Read %q: %v", name, err)
}
return buf.Bytes(), nil
}
var __000001_init_down_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x09\xf2\x0f\x50\x08\x71\x74\xf2\x71\x55\x48\xca\xc9\x4f\xce\x2e\xb6\xe6\x02\x04\x00\x00\xff\xff\x45\xbe\x01\x0f\x13\x00\x00\x00")
func _000001_init_down_sql() ([]byte, error) {
return bindata_read(
__000001_init_down_sql,
"000001_init.down.sql",
)
}
var __000001_init_up_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x64\x8f\xbf\x6e\x83\x30\x10\x87\x67\xfc\x14\xb7\x58\x18\x89\x4c\x95\x3a\xa4\x93\x93\x1c\x8d\x55\x20\x95\xb9\xb4\x61\x8a\x28\xbe\xa8\x56\x49\x8a\xc0\x1d\xfa\xf6\x15\x91\xca\x90\x6c\xf7\xfb\x4e\xdf\xfd\x59\x5b\xd4\x84\x40\x7a\x95\x23\x98\x0c\xca\x1d\x01\x1e\x4c\x45\x15\x7c\x74\xdf\xed\xd7\x08\x4a\x44\xde\xc1\x9b\xb6\xeb\xad\xb6\xea\xe1\x31\x49\x45\xe4\x2f\x23\x0f\xe1\xd8\x04\xd8\x68\x42\x32\x05\x5e\xc5\x72\x9f\xe7\xb0\xc1\x4c\xef\x73\x52\x15\xd9\x6c\xea\xa8\x58\xd6\x0b\x79\x5e\x48\x07\x72\xbb\x94\xc5\x52\x9e\xe2\x14\xe2\x72\xf7\x1e\x27\xd3\xac\xbe\x19\xf8\x12\x8e\x77\x3b\xc6\xf6\x93\xcf\x0d\xac\xcc\xb3\x29\x29\x15\x51\xf8\xed\x19\x08\x0f\xd7\xda\x87\x6e\x0e\x27\xcf\x9d\x1b\xff\x53\x3b\x70\x13\x78\x3a\x6d\x36\x7f\x7a\x77\x8b\x1c\x77\x7c\x83\x5e\xad\x29\xb4\xad\xe1\x05\x6b\x50\xde\xa5\x30\x3f\x99\x88\xe4\x49\xfc\x05\x00\x00\xff\xff\xa2\x33\x30\x8e\x29\x01\x00\x00")
func _000001_init_up_sql() ([]byte, error) {
return bindata_read(
__000001_init_up_sql,
"000001_init.up.sql",
)
}
// Asset loads and returns the asset for the given name.
// It returns an error if the asset could not be found or
// could not be loaded.
func Asset(name string) ([]byte, error) {
cannonicalName := strings.Replace(name, "\\", "/", -1)
if f, ok := _bindata[cannonicalName]; ok {
return f()
}
return nil, fmt.Errorf("Asset %s not found", name)
}
// AssetNames returns the names of the assets.
func AssetNames() []string {
names := make([]string, 0, len(_bindata))
for name := range _bindata {
names = append(names, name)
}
return names
}
// _bindata is a table, holding each asset generator, mapped to its name.
var _bindata = map[string]func() ([]byte, error){
"000001_init.down.sql": _000001_init_down_sql,
"000001_init.up.sql": _000001_init_up_sql,
}
// AssetDir returns the file names below a certain
// directory embedded in the file by go-bindata.
// For example if you run go-bindata on data/... and data contains the
// following hierarchy:
// data/
// foo.txt
// img/
// a.png
// b.png
// then AssetDir("data") would return []string{"foo.txt", "img"}
// AssetDir("data/img") would return []string{"a.png", "b.png"}
// AssetDir("foo.txt") and AssetDir("notexist") would return an error
// AssetDir("") will return []string{"data"}.
func AssetDir(name string) ([]string, error) {
node := _bintree
if len(name) != 0 {
cannonicalName := strings.Replace(name, "\\", "/", -1)
pathList := strings.Split(cannonicalName, "/")
for _, p := range pathList {
node = node.Children[p]
if node == nil {
return nil, fmt.Errorf("Asset %s not found", name)
}
}
}
if node.Func != nil {
return nil, fmt.Errorf("Asset %s not found", name)
}
rv := make([]string, 0, len(node.Children))
for name := range node.Children {
rv = append(rv, name)
}
return rv, nil
}
type _bintree_t struct {
Func func() ([]byte, error)
Children map[string]*_bintree_t
}
var _bintree = &_bintree_t{nil, map[string]*_bintree_t{
"000001_init.down.sql": &_bintree_t{_000001_init_down_sql, map[string]*_bintree_t{
}},
"000001_init.up.sql": &_bintree_t{_000001_init_up_sql, map[string]*_bintree_t{
}},
}}

View file

@ -0,0 +1 @@
DROP TABLE blocks;

View file

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS blocks (
id VARCHAR(36),
insert_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
parent_id VARCHAR(36),
schema BIGINT,
type TEXT,
title TEXT,
fields TEXT,
create_at BIGINT,
update_at BIGINT,
delete_at BIGINT,
PRIMARY KEY (id, insert_at)
);

View file

@ -0,0 +1,46 @@
package sqlstore
import (
"database/sql"
"log"
)
// SQLStore is a SQL database
type SQLStore struct {
db *sql.DB
dbType string
}
// New creates a new SQL implementation of the store
func New(dbType, connectionString string) (*SQLStore, error) {
log.Println("connectDatabase")
var err error
db, err := sql.Open(dbType, connectionString)
if err != nil {
log.Fatal("connectDatabase: ", err)
return nil, err
}
err = db.Ping()
if err != nil {
log.Println(`Database Ping failed`)
return nil, err
}
store := &SQLStore{
db: db,
dbType: dbType,
}
err = store.Migrate()
if err != nil {
log.Println(`Table creation failed`)
return nil, err
}
return store, nil
}
func (s *SQLStore) Shutdown() error {
return s.db.Close()
}

View file

@ -0,0 +1,28 @@
package sqlstore
import (
"os"
"testing"
"github.com/stretchr/testify/require"
)
func SetupTests(t *testing.T) (*SQLStore, func()) {
dbType := os.Getenv("OT_STORE_TEST_DB_TYPE")
if dbType == "" {
dbType = "sqlite3"
}
connectionString := os.Getenv("OT_STORE_TEST_CONN_STRING")
if connectionString == "" {
connectionString = ":memory:"
}
store, err := New(dbType, connectionString)
require.Nil(t, err)
tearDown := func() {
err = store.Shutdown()
require.Nil(t, err)
}
return store, tearDown
}

View file

@ -0,0 +1,16 @@
package store
import "github.com/mattermost/mattermost-octo-tasks/server/model"
// Store represents the abstraction of the data storage
type Store interface {
GetBlocksWithParentAndType(parentID string, blockType string) ([]model.Block, error)
GetBlocksWithParent(parentID string) ([]model.Block, error)
GetBlocksWithType(blockType string) ([]model.Block, error)
GetSubTree(blockID string) ([]model.Block, error)
GetAllBlocks() ([]model.Block, error)
GetParentID(blockID string) (string, error)
InsertBlock(block model.Block) error
DeleteBlock(blockID string) error
Shutdown() error
}

79
server/web/webserver.go Normal file
View file

@ -0,0 +1,79 @@
package web
import (
"fmt"
"log"
"net/http"
"os"
"path"
"path/filepath"
"github.com/gorilla/mux"
)
type RoutedService interface {
RegisterRoutes(*mux.Router)
}
type WebServer struct {
router *mux.Router
rootPath string
port int
ssl bool
}
func NewWebServer(rootPath string, port int, ssl bool) *WebServer {
r := mux.NewRouter()
ws := &WebServer{
router: r,
rootPath: rootPath,
port: port,
ssl: ssl,
}
return ws
}
func (ws *WebServer) AddRoutes(rs RoutedService) {
rs.RegisterRoutes(ws.router)
}
func (ws *WebServer) registerRoutes() {
ws.router.PathPrefix("/static").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(ws.rootPath, "static")))))
ws.router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeFile(w, r, path.Join(ws.rootPath, "index.html"))
})
}
func (ws *WebServer) Start() error {
ws.registerRoutes()
http.Handle("/", ws.router)
urlPort := fmt.Sprintf(`:%d`, ws.port)
var isSSL = ws.ssl && fileExists("./cert/cert.pem") && fileExists("./cert/key.pem")
if isSSL {
log.Println("https server started on ", urlPort)
err := http.ListenAndServeTLS(urlPort, "./cert/cert.pem", "./cert/key.pem", nil)
if err != nil {
return err
}
return nil
}
log.Println("http server started on ", urlPort)
err := http.ListenAndServe(urlPort, nil)
if err != nil {
return err
}
return nil
}
// FileExists returns true if a file exists at the path
func fileExists(path string) bool {
_, err := os.Stat(path)
if os.IsNotExist(err) {
return false
}
return err == nil
}

View file

@ -1,4 +1,4 @@
package main
package ws
import (
"sync"

View file

@ -1,13 +1,18 @@
package main
package ws
import (
"log"
"net/http"
"sync"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
)
func (ws *WSServer) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/ws/onchange", ws.handleWebSocketOnChange)
}
// AddListener adds a listener for a blockID's change
func (ws *WSServer) AddListener(client *websocket.Conn, blockID string) {
ws.mu.Lock()
@ -105,7 +110,7 @@ func (ws *WSServer) handleWebSocketOnChange(w http.ResponseWriter, r *http.Reque
}
}
func (ws *WSServer) broadcastBlockChangeToWebsocketClients(blockIDs []string) {
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)

View file

@ -23,6 +23,9 @@ export default function App() {
<Route path="/login">
<LoginPage />
</Route>
<Route path="/">
<BoardPage />
</Route>
<Route path="/board">
<BoardPage />
</Route>

View file

@ -50,7 +50,7 @@ class Archiver {
// TODO: Remove or reuse link
}
static importFullArchive(onImported?: () => void): void {
static importFullArchive(onComplete?: () => void): void {
const input = document.createElement("input")
input.type = "file"
input.accept = ".octo"
@ -73,7 +73,7 @@ class Archiver {
await mutator.importFullArchive(filteredBlocks)
Utils.log(`Import completed`)
onImported?.()
onComplete?.()
}
input.style.display = "none"

View file

@ -1,5 +1,5 @@
import { IBlock } from "./octoTypes"
import { Utils } from "./utils"
import { IBlock } from "../octoTypes"
import { Utils } from "../utils"
class Block implements IBlock {
id: string = Utils.createGuid()
@ -7,7 +7,6 @@ class Block implements IBlock {
parentId: string
type: string
title: string
order: number
fields: Record<string, any> = {}
createAt: number = Date.now()
updateAt: number = 0
@ -38,7 +37,6 @@ class Block implements IBlock {
this.fields = block.fields ? { ...block.fields } : {}
this.title = block.title
this.order = block.order
this.createAt = block.createAt || now
this.updateAt = block.updateAt || now

View file

@ -1,5 +1,5 @@
import { FilterGroup } from "../filterGroup"
import { Block } from "./block"
import { FilterGroup } from "./filterGroup"
type IViewType = "board" | "table" | "calendar" | "list" | "gallery"
type ISortOption = { propertyId: "__name" | string, reversed: boolean }

View file

@ -0,0 +1,10 @@
import { Block } from "./block"
class CommentBlock extends Block {
constructor(block: any = {}) {
super(block)
this.type = "comment"
}
}
export { CommentBlock }

View file

@ -0,0 +1,17 @@
import { IOrderedBlock } from "../octoTypes"
import { Block } from "./block"
class ImageBlock extends Block implements IOrderedBlock {
get order(): number { return this.fields.order as number }
set order(value: number) { this.fields.order = value }
get url(): string { return this.fields.url as string }
set url(value: string) { this.fields.url = value }
constructor(block: any = {}) {
super(block)
this.type = "image"
}
}
export { ImageBlock }

View file

@ -0,0 +1,14 @@
import { IOrderedBlock } from "../octoTypes"
import { Block } from "./block"
class TextBlock extends Block implements IOrderedBlock {
get order(): number { return this.fields.order as number }
set order(value: number) { this.fields.order = value }
constructor(block: any = {}) {
super(block)
this.type = "text"
}
}
export { TextBlock }

View file

@ -1,9 +1,11 @@
import { Board, IPropertyOption, IPropertyTemplate } from "./board"
import { BoardView } from "./boardView"
import { Card } from "./card"
import { Block } from "./blocks/block"
import { Board, IPropertyOption, IPropertyTemplate } from "./blocks/board"
import { BoardView } from "./blocks/boardView"
import { Card } from "./blocks/card"
import { CardFilter } from "./cardFilter"
import octoClient from "./octoClient"
import { IBlock } from "./octoTypes"
import { OctoUtils } from "./octoUtils"
import { Utils } from "./utils"
type Group = { option: IPropertyOption, cards: Card[] }
@ -29,21 +31,13 @@ class BoardTree {
async sync() {
const blocks = await octoClient.getSubtree(this.boardId)
this.rebuild(blocks)
this.rebuild(OctoUtils.hydrateBlocks(blocks))
}
private rebuild(blocks: IBlock[]) {
const boardBlock = blocks.find(block => block.type === "board")
if (boardBlock) {
this.board = new Board(boardBlock)
}
const viewBlocks = blocks.filter(block => block.type === "view")
this.views = viewBlocks.map(o => new BoardView(o))
const cardBlocks = blocks.filter(block => block.type === "card")
this.allCards = cardBlocks.map(o => new Card(o))
private rebuild(blocks: Block[]) {
this.board = blocks.find(block => block.type === "board") as Board
this.views = blocks.filter(block => block.type === "view") as BoardView[]
this.allCards = blocks.filter(block => block.type === "card") as Card[]
this.cards = []
this.ensureMinimumSchema()

View file

@ -1,5 +1,5 @@
import { IPropertyTemplate } from "./board"
import { Card } from "./card"
import { IPropertyTemplate } from "./blocks/board"
import { Card } from "./blocks/card"
import { FilterClause } from "./filterClause"
import { FilterGroup } from "./filterGroup"
import { Utils } from "./utils"

View file

@ -1,11 +1,13 @@
import { Card } from "./card"
import { Block } from "./blocks/block"
import { Card } from "./blocks/card"
import octoClient from "./octoClient"
import { IBlock } from "./octoTypes"
import { IBlock, IOrderedBlock } from "./octoTypes"
import { OctoUtils } from "./octoUtils"
class CardTree {
card: Card
comments: IBlock[]
contents: IBlock[]
contents: IOrderedBlock[]
isSynched: boolean
constructor(private cardId: string) {
@ -13,19 +15,18 @@ class CardTree {
async sync() {
const blocks = await octoClient.getSubtree(this.cardId)
this.rebuild(blocks)
this.rebuild(OctoUtils.hydrateBlocks(blocks))
}
private rebuild(blocks: IBlock[]) {
this.card = new Card(blocks.find(o => o.id === this.cardId))
private rebuild(blocks: Block[]) {
this.card = blocks.find(o => o.id === this.cardId) as Card
this.comments = blocks
.filter(block => block.type === "comment")
.sort((a, b) => a.createAt - b.createAt)
this.contents = blocks
.filter(block => block.type === "text" || block.type === "image")
.sort((a, b) => a.order - b.order)
const contentBlocks = blocks.filter(block => block.type === "text" || block.type === "image") as IOrderedBlock[]
this.contents = contentBlocks.sort((a, b) => a.order - b.order)
this.isSynched = true
}

View file

@ -1,7 +1,7 @@
import React from "react"
import { Block } from "../block"
import { IPropertyTemplate } from "../board"
import { Card } from "../card"
import { Block } from "../blocks/block"
import { IPropertyTemplate } from "../blocks/board"
import { Card } from "../blocks/card"
import { Menu } from "../menu"
import mutator from "../mutator"
import { OctoUtils } from "../octoUtils"

View file

@ -1,24 +1,23 @@
import React from "react"
import { Archiver } from "../archiver"
import { BlockIcons } from "../blockIcons"
import { IPropertyOption } from "../board"
import { IPropertyOption } from "../blocks/board"
import { Card } from "../blocks/card"
import { BoardTree } from "../boardTree"
import { Card } from "../card"
import { CardFilter } from "../cardFilter"
import ViewMenu from "../components/viewMenu"
import MenuWrapper from "../widgets/menuWrapper"
import Menu from "../widgets/menu"
import { Constants } from "../constants"
import { randomEmojiList } from "../emojiList"
import { Menu as OldMenu } from "../menu"
import mutator from "../mutator"
import { OctoUtils } from "../octoUtils"
import { Utils } from "../utils"
import Menu from "../widgets/menu"
import MenuWrapper from "../widgets/menuWrapper"
import { BoardCard } from "./boardCard"
import { BoardColumn } from "./boardColumn"
import Button from "./button"
import { Editable } from "./editable"
import { CardDialog } from "./cardDialog"
import { Editable } from "./editable"
import RootPortal from "./rootPortal"
type Props = {
@ -96,8 +95,8 @@ class BoardComponent extends React.Component<Props, State> {
<MenuWrapper>
<div className="octo-button octo-icon">{board.icon}</div>
<Menu>
<Menu.Text id='random' name='Random' onClick={() => mutator.changeIcon(board, undefined, "remove icon")}/>
<Menu.Text id='remove' name='Remove Icon' onClick={() => mutator.changeIcon(board, BlockIcons.shared.randomIcon())}/>
<Menu.Text id='random' name='Random' onClick={() => mutator.changeIcon(board, BlockIcons.shared.randomIcon())}/>
<Menu.Text id='remove' name='Remove Icon' onClick={() => mutator.changeIcon(board, undefined, "remove icon")}/>
</Menu>
</MenuWrapper>
: undefined}
@ -149,7 +148,7 @@ class BoardComponent extends React.Component<Props, State> {
<div className="octo-board-header-cell">
<div className="octo-label" title={`Items with an empty ${boardTree.groupByProperty?.name} property will go here. This column cannot be removed.`}>{`No ${boardTree.groupByProperty?.name}`}</div>
<Button text={`${boardTree.emptyGroupCards.length}`} />
<Button>{`${boardTree.emptyGroupCards.length}`}</Button>
<div className="octo-spacer" />
<Button><div className="imageOptions" /></Button>
<Button onClick={() => { this.addCard(undefined) }}><div className="imageAdd" /></Button>
@ -173,7 +172,7 @@ class BoardComponent extends React.Component<Props, State> {
className={`octo-label ${group.option.color}`}
text={group.option.value}
onChanged={(text) => { this.propertyNameChanged(group.option, text) }} />
<Button text={`${group.cards.length}`} />
<Button>{`${group.cards.length}`}</Button>
<div className="octo-spacer" />
<MenuWrapper>
<Button><div className="imageOptions" /></Button>
@ -190,7 +189,7 @@ class BoardComponent extends React.Component<Props, State> {
)}
<div className="octo-board-header-cell">
<Button text="+ Add a group" onClick={(e) => { this.addGroupClicked() }} />
<Button onClick={(e) => { this.addGroupClicked() }}>+ Add a group</Button>
</div>
</div>
@ -210,7 +209,7 @@ class BoardComponent extends React.Component<Props, State> {
onDragStart={() => { this.draggedCard = card }}
onDragEnd={() => { this.draggedCard = undefined }} />
)}
<Button text="+ New" onClick={() => { this.addCard(undefined) }} />
<Button onClick={() => { this.addCard(undefined) }} >+ New</Button>
</BoardColumn>
{/* Columns */}
@ -226,7 +225,7 @@ class BoardComponent extends React.Component<Props, State> {
onDragStart={() => { this.draggedCard = card }}
onDragEnd={() => { this.draggedCard = undefined }} />
)}
<Button text="+ New" onClick={() => { this.addCard(group.option.value) }} />
<Button onClick={() => { this.addCard(group.option.value) }} >+ New</Button>
</BoardColumn>
)}
</div>
@ -243,10 +242,11 @@ class BoardComponent extends React.Component<Props, State> {
const card = new Card()
card.parentId = boardTree.board.id
card.properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
card.icon = BlockIcons.shared.randomIcon()
if (boardTree.groupByProperty) {
card.properties[boardTree.groupByProperty.id] = groupByValue
}
await mutator.insertBlock(card, "add card", async () => { await this.setState({shownCard: card}) }, async () => { await this.setState({shownCard: undefined}) })
await mutator.insertBlock(card, "add card", async () => { this.setState({shownCard: card}) }, async () => { this.setState({shownCard: undefined}) })
}
async propertyNameChanged(option: IPropertyOption, text: string) {
@ -266,6 +266,7 @@ class BoardComponent extends React.Component<Props, State> {
{ id: "exportBoardArchive", name: "Export board archive" },
{ id: "testAdd100Cards", name: "TEST: Add 100 cards" },
{ id: "testAdd1000Cards", name: "TEST: Add 1,000 cards" },
{ id: "testRandomizeIcons", name: "TEST: Randomize icons" },
]
OldMenu.shared.onMenuClicked = async (id: string) => {
@ -282,6 +283,10 @@ class BoardComponent extends React.Component<Props, State> {
this.testAddCards(1000)
break
}
case "testRandomizeIcons": {
this.testRandomizeIcons()
break
}
}
}
OldMenu.shared.showAtElement(e.target as HTMLElement)
@ -310,6 +315,14 @@ class BoardComponent extends React.Component<Props, State> {
}
}
private async testRandomizeIcons() {
const { boardTree } = this.props
for (const card of boardTree.cards) {
mutator.changeIcon(card, BlockIcons.shared.randomIcon(), "randomize icon")
}
}
private async propertiesClicked(e: React.MouseEvent) {
const { boardTree } = this.props
const { activeView } = boardTree

View file

@ -6,7 +6,7 @@ type Props = {
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
style?: React.CSSProperties
backgroundColor?: string
text?: string
children?: React.ReactNode
title?: string
}
@ -20,7 +20,6 @@ export default class Button extends React.Component<Props> {
style={style}
title={this.props.title}>
{this.props.children}
{this.props.text}
</div>)
}
}

View file

@ -1,10 +1,10 @@
import React from "react"
import { Card } from "../blocks/card"
import { BoardTree } from "../boardTree"
import { Card } from "../card"
import Menu from "../widgets/menu"
import Dialog from "./dialog"
import CardDetail from "./cardDetail"
import mutator from "../mutator"
import Menu from "../widgets/menu"
import CardDetail from "./cardDetail"
import Dialog from "./dialog"
type Props = {
boardTree: BoardTree

View file

@ -1,10 +1,10 @@
import React from "react"
import { Archiver } from "../archiver"
import { Board } from "../board"
import { Board } from "../blocks/board"
import { BoardTree } from "../boardTree"
import { Menu, MenuOption } from "../menu"
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import mutator from "../mutator"
import { IPageController } from "../octoTypes"
import { WorkspaceTree } from "../workspaceTree"
type Props = {
@ -32,7 +32,19 @@ class Sidebar extends React.Component<Props> {
<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>
<MenuWrapper>
<div className="octo-button square octo-hover-item"><div className="imageOptions" /></div>
<Menu>
<Menu.Text id="delete" name="Delete board" onClick={async () => {
const nextBoardId = boards.length > 1 ? boards.find(o => o.id !== board.id).id : undefined
mutator.deleteBlock(
board,
"delete block",
async () => { nextBoardId && this.props.showBoard(nextBoardId!) },
async () => { this.props.showBoard(board.id) },
)}} />
</Menu>
</MenuWrapper>
</div>
)
})
@ -44,66 +56,17 @@ class Sidebar extends React.Component<Props> {
<div className="octo-spacer"></div>
<div className="octo-button" onClick={(e) => { this.settingsClicked(e) }}>Settings</div>
<MenuWrapper>
<div className="octo-button">Settings</div>
<Menu position="top">
<Menu.Text id="import" name="Import Archive" onClick={async () => Archiver.importFullArchive(() => { this.forceUpdate() })} />
<Menu.Text id="export" name="Export Archive" onClick={async () => Archiver.exportFullArchive()} />
</Menu>
</MenuWrapper>
</div>
)
}
private showOptions(e: React.MouseEvent, board: Board) {
const { 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) {
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(() => {
this.forceUpdate()
})
break
}
case "export": {
Archiver.exportFullArchive()
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)
}

View file

@ -1,23 +1,22 @@
import React from "react"
import { Archiver } from "../archiver"
import { Block } from "../block"
import { BlockIcons } from "../blockIcons"
import { IPropertyTemplate } from "../board"
import { IPropertyTemplate } from "../blocks/board"
import { Card } from "../blocks/card"
import { BoardTree } from "../boardTree"
import { Card } from "../card"
import ViewMenu from "../components/viewMenu"
import MenuWrapper from "../widgets/menuWrapper"
import Menu from "../widgets/menu"
import { CsvExporter } from "../csvExporter"
import { Menu as OldMenu } from "../menu"
import mutator from "../mutator"
import { OctoUtils } from "../octoUtils"
import { Utils } from "../utils"
import Menu from "../widgets/menu"
import MenuWrapper from "../widgets/menuWrapper"
import Button from "./button"
import { Editable } from "./editable"
import { TableRow } from "./tableRow"
import { CardDialog } from "./cardDialog"
import { Editable } from "./editable"
import RootPortal from "./rootPortal"
import { TableRow } from "./tableRow"
type Props = {
boardTree?: BoardTree
@ -92,8 +91,8 @@ class TableComponent extends React.Component<Props, State> {
<MenuWrapper>
<div className="octo-button octo-icon">{board.icon}</div>
<Menu>
<Menu.Text id='random' name='Random' onClick={() => mutator.changeIcon(board, undefined, "remove icon")}/>
<Menu.Text id='remove' name='Remove Icon' onClick={() => mutator.changeIcon(board, BlockIcons.shared.randomIcon())}/>
<Menu.Text id='random' name='Random' onClick={() => mutator.changeIcon(board, BlockIcons.shared.randomIcon())}/>
<Menu.Text id='remove' name='Remove Icon' onClick={() => mutator.changeIcon(board, undefined, "remove icon")}/>
</Menu>
</MenuWrapper>
: undefined}
@ -361,6 +360,7 @@ class TableComponent extends React.Component<Props, State> {
const card = new Card()
card.parentId = boardTree.board.id
card.icon = BlockIcons.shared.randomIcon()
await mutator.insertBlock(
card,
"add card",

View file

@ -1,6 +1,6 @@
import React from "react"
import { BoardTree } from "../boardTree"
import { Card } from "../card"
import { Card } from "../blocks/card"
import mutator from "../mutator"
import { OctoUtils } from "../octoUtils"
import { Editable } from "./editable"

View file

@ -1,7 +1,7 @@
import React from "react"
import { Board } from "../board"
import { Board } from "../blocks/board"
import { BoardView } from "../blocks/boardView"
import { BoardTree } from "../boardTree"
import { BoardView } from "../boardView"
import mutator from "../mutator"
import { Utils } from "../utils"
import Menu from "../widgets/menu"

View file

@ -1,6 +1,5 @@
import React from "react"
import { BoardTree } from "../boardTree"
import { Card } from "../card"
import { Utils } from "../utils"
import { WorkspaceTree } from "../workspaceTree"
import { BoardComponent } from "./boardComponent"

View file

@ -1,5 +1,5 @@
import { BoardView } from "./blocks/boardView"
import { BoardTree } from "./boardTree"
import { BoardView } from "./boardView"
import { OctoUtils } from "./octoUtils"
import { Utils } from "./utils"

View file

@ -13,9 +13,11 @@ class FilterClause {
case "notIncludes": return "doesn't include"
case "isEmpty": return "is empty"
case "isNotEmpty": return "is not empty"
default: {
Utils.assertFailure()
return "(unknown)"
}
}
Utils.assertFailure()
return "(unknown)"
}
constructor(o: any = {}) {

View file

@ -1,11 +1,12 @@
import { Block } from "./block"
import { Board, IPropertyOption, IPropertyTemplate, PropertyType } from "./board"
import { Block } from "./blocks/block"
import { Board, IPropertyOption, IPropertyTemplate, PropertyType } from "./blocks/board"
import { BoardView, ISortOption } from "./blocks/boardView"
import { Card } from "./blocks/card"
import { ImageBlock } from "./blocks/imageBlock"
import { BoardTree } from "./boardTree"
import { BoardView, ISortOption } from "./boardView"
import { Card } from "./card"
import { FilterGroup } from "./filterGroup"
import octoClient from "./octoClient"
import { IBlock } from "./octoTypes"
import { IBlock, IOrderedBlock } from "./octoTypes"
import undoManager from "./undomanager"
import { Utils } from "./utils"
@ -14,9 +15,6 @@ import { Utils } from "./utils"
// It also ensures that the Undo-manager is called for each action
//
class Mutator {
constructor() {
}
async insertBlock(block: IBlock, description: string = "add", afterRedo?: () => Promise<void>, beforeUndo?: () => Promise<void>) {
await undoManager.perform(
async () => {
@ -95,7 +93,7 @@ class Mutator {
)
}
async changeOrder(block: IBlock, order: number, description: string = "change order") {
async changeOrder(block: IOrderedBlock, order: number, description: string = "change order") {
const oldValue = block.order
await undoManager.perform(
async () => {
@ -494,8 +492,9 @@ class Mutator {
return undefined
}
const block = new Block({ type: "image", parentId, order })
block.fields.url = url
const block = new ImageBlock({ parentId })
block.order = order
block.url = url
await undoManager.perform(
async () => {
@ -510,21 +509,21 @@ class Mutator {
return block
}
async undo() {
await undoManager.undo()
}
async undo() {
await undoManager.undo()
}
undoDescription(): string | undefined {
return undoManager.undoDescription
}
undoDescription(): string | undefined {
return undoManager.undoDescription
}
async redo() {
await undoManager.redo()
}
async redo() {
await undoManager.redo()
}
redoDescription(): string | undefined {
return undoManager.redoDescription
}
redoDescription(): string | undefined {
return undoManager.redoDescription
}
}
const mutator = new Mutator()

View file

@ -1,5 +1,3 @@
import { Card } from "./card"
// A block is the fundamental data type
interface IBlock {
id: string
@ -8,7 +6,6 @@ interface IBlock {
schema: number
type: string
title?: string
order: number
fields: Record<string, any>
createAt: number
@ -16,6 +13,10 @@ interface IBlock {
deleteAt: number
}
interface IOrderedBlock extends IBlock {
order: number
}
// These are methods exposed by the top-level page to components
interface IPageController {
showBoard(boardId: string): void
@ -24,4 +25,4 @@ interface IPageController {
setSearchText(text?: string): void
}
export { IBlock, IPageController }
export { IBlock, IOrderedBlock, IPageController }

View file

@ -1,12 +1,16 @@
import React from "react"
import { IPropertyTemplate } from "./board"
import { Block } from "./blocks/block"
import { Board, IPropertyTemplate } from "./blocks/board"
import { BoardView, ISortOption } from "./blocks/boardView"
import { Card } from "./blocks/card"
import { CommentBlock } from "./blocks/commentBlock"
import { ImageBlock } from "./blocks/imageBlock"
import { TextBlock } from "./blocks/textBlock"
import { BoardTree } from "./boardTree"
import { ISortOption } from "./boardView"
import { Card } from "./card"
import { Editable } from "./components/editable"
import { Menu } from "./menu"
import mutator from "./mutator"
import { IBlock } from "./octoTypes"
import { IBlock, IOrderedBlock } from "./octoTypes"
import { Utils } from "./utils"
class OctoUtils {
@ -103,7 +107,7 @@ class OctoUtils {
return element
}
static getOrderBefore(block: IBlock, blocks: IBlock[]): number {
static getOrderBefore(block: IOrderedBlock, blocks: IOrderedBlock[]): number {
const index = blocks.indexOf(block)
if (index === 0) {
return block.order / 2
@ -112,7 +116,7 @@ class OctoUtils {
return (block.order + previousBlock.order) / 2
}
static getOrderAfter(block: IBlock, blocks: IBlock[]): number {
static getOrderAfter(block: IOrderedBlock, blocks: IOrderedBlock[]): number {
const index = blocks.indexOf(block)
if (index === blocks.length - 1) {
return block.order + 1000
@ -151,6 +155,24 @@ class OctoUtils {
}
Menu.shared.showAtElement(e.target as HTMLElement)
}
static hydrateBlock(block: IBlock): Block {
switch (block.type) {
case "board": { return new Board(block) }
case "view": { return new BoardView(block) }
case "card": { return new Card(block) }
case "text": { return new TextBlock(block) }
case "image": { return new ImageBlock(block) }
case "comment": { return new CommentBlock(block) }
default: {
Utils.assertFailure(`Can't hydrate unknown block type: ${block.type}`)
}
}
}
static hydrateBlocks(blocks: IBlock[]): Block[] {
return blocks.map( block => this.hydrateBlock(block) )
}
}
export { OctoUtils }

View file

@ -1,15 +1,12 @@
import React from "react"
import ReactDOM from "react-dom"
import { BoardView } from "../blocks/boardView"
import { BoardTree } from "../boardTree"
import { BoardView } from "../boardView"
import { Card } from "../card"
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 { Utils } from "../utils"
import { WorkspaceTree } from "../workspaceTree"

View file

@ -1,8 +1,7 @@
import React from 'react'
import { Archiver } from "../archiver"
import { Board } from "../board"
import { Board } from "../blocks/board"
import Button from '../components/button'
import mutator from '../mutator'
import octoClient from "../octoClient"
import { IBlock } from "../octoTypes"
import { Utils } from "../utils"

View file

@ -0,0 +1,27 @@
.LoginPage {
border: 1px solid #cccccc;
border-radius: 15px;
width: 450px;
height: 400px;
margin: auto;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
.username, .password {
margin-bottom: 5px;
label {
display: inline-block;
width: 140px;
}
input {
display: inline-block;
width: 250px;
border: 1px solid #cccccc;
border-radius: 4px;
}
}
.Button {
margin-top: 10px;
}
}

View file

@ -1,5 +1,9 @@
import React from "react"
import Button from '../components/button'
import './loginPage.scss'
type Props = {}
type State = {
@ -14,23 +18,30 @@ export default class LoginPage extends React.Component<Props, State> {
}
handleLogin = () => {
console.log("Logging in");
console.log("Logging in")
}
public render(): React.ReactNode {
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 className='username'>
<label htmlFor='login-username'>Username</label>
<input
id='login-username'
value={this.state.username}
onChange={(e) => this.setState({username: e.target.value})}
/>
</div>
<div className='password'>
<label htmlFor='login-username'>Password</label>
<input
id='login-password'
type='password'
value={this.state.password}
onChange={(e) => this.setState({password: e.target.value})}
/>
</div>
<Button onClick={this.handleLogin}>Login</Button>
</div>
)
}

View file

@ -1,4 +1,4 @@
import { IPropertyTemplate, PropertyType } from "./board"
import { IPropertyTemplate, PropertyType } from "./blocks/board"
import { Menu } from "./menu"
import { Utils } from "./utils"

View file

@ -1,7 +1,7 @@
interface UndoCommand {
checkpoint: number
undo: () => void
redo: () => void
undo: () => Promise<void>
redo: () => Promise<void>
description?: string
}
@ -54,8 +54,8 @@ class UndoManager {
}
async perform(
redo: () => void,
undo: () => void,
redo: () => Promise<void>,
undo: () => Promise<void>,
description?: string,
isDiscardable = false
): Promise<UndoManager> {
@ -64,7 +64,10 @@ class UndoManager {
}
registerUndo(
command: { undo: () => void; redo: () => void },
command: {
undo: () => Promise<void>,
redo: () => Promise<void>
},
description?: string,
isDiscardable = false
): UndoManager {

View file

@ -117,6 +117,7 @@ class TextOption extends React.Component<TextOptionProps> {
type MenuProps = {
children: React.ReactNode
position?: 'top'|'bottom'
}
export default class Menu extends React.Component<MenuProps> {
@ -127,10 +128,11 @@ export default class Menu extends React.Component<MenuProps> {
static Text = TextOption
render() {
const {position, children} = this.props
return (
<div className="Menu menu noselect">
<div className={"Menu menu noselect " + (position ? position : "bottom")}>
<div className="menu-options">
{this.props.children}
{children}
</div>
</div>
)

View file

@ -82,7 +82,7 @@ export default class MenuWrapper extends React.PureComponent<Props, State> {
return (
<div
className={'MenuWrapper'}
className={'MenuWrapper menu-wrapper'}
onClick={this.toggle}
ref={this.node}
>

View file

@ -1,18 +1,18 @@
import { Board } from "./board"
import { Block } from "./blocks/block"
import { Board } from "./blocks/board"
import octoClient from "./octoClient"
import { IBlock } from "./octoTypes"
import { OctoUtils } from "./octoUtils"
class WorkspaceTree {
boards: Board[] = []
async sync() {
const blocks = await octoClient.getBlocks(undefined, "board")
this.rebuild(blocks)
this.rebuild(OctoUtils.hydrateBlocks(blocks))
}
private rebuild(blocks: IBlock[]) {
const boardBlocks = blocks.filter(block => block.type === "board")
this.boards = boardBlocks.map(o => new Board(o))
private rebuild(blocks: Block[]) {
this.boards = blocks.filter(block => block.type === "board") as Board[]
}
}

View file

@ -329,6 +329,14 @@ hr {
background-color: #ffffff;
}
.menu.top {
bottom: 100%;
}
.menu-wrapper {
position: relative;
}
.menu-options {
display: flex;
flex-direction: column;
@ -336,6 +344,8 @@ hr {
list-style: none;
padding: 0;
margin: 0;
color: rgb(55, 53, 47);
}
.menu-option {
@ -454,7 +464,7 @@ hr {
align-items: center;
}
.octo-frame .octo-icontitle .octo-icon {
.octo-frame > .octo-icontitle .octo-icon {
font-size: 36px;
line-height: 36px;
margin-right: 15px;

View file

@ -6,7 +6,7 @@
"no-bitwise": true,
"no-conditional-assignment": true,
"no-consecutive-blank-lines": true,
"indent": [true, "tabs"],
"indent": [false, "tabs"],
"member-access": [true, "no-public"],
"semicolon": [true, "never"],
"variable-name": [

View file

@ -26,12 +26,12 @@ function makeCommonConfig() {
loader: "file-loader",
},
{
test: /\.s[ac]ss$/i,
use: [
'style-loader',
'css-loader',
'sass-loader',
],
test: /\.s[ac]ss$/i,
use: [
'style-loader',
'css-loader',
'sass-loader',
],
},
{
test: /\.(tsx?|js|jsx|html)$/,
@ -60,14 +60,15 @@ function makeCommonConfig() {
title: "OCTO",
chunks: ["main"],
template: "html-templates/page.ejs",
filename: 'index.html'
filename: 'index.html',
publicPath: '/'
}),
],
entry: {
main: "./src/client/main.tsx",
},
output: {
filename: "[name].js",
filename: "static/[name].js",
path: outpath
}
}