1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-12-24 13:43:12 +02:00

Merge remote-tracking branch 'origin/main' into auth

This commit is contained in:
Jesús Espino 2021-01-11 13:56:26 +01:00
commit daae244cba
202 changed files with 13377 additions and 1301 deletions

36
.github/workflows/build-mac.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: Build-Mac
on: workflow_dispatch
jobs:
macos:
runs-on: macos-10.15
steps:
- name: Checkout
uses: actions/checkout@v2
- name: npm install
run: cd webapp; npm install --no-optional
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.15
- name: List Xcode versions
run: ls -n /Applications/ | grep Xcode*
- name: Build macOS
run: make mac-app
env:
DEVELOPER_DIR: /Applications/Xcode_12.3.app/Contents/Developer
- name: Upload macOS package
uses: actions/upload-artifact@v1
with:
name: tasks-mac.zip
path: ${{ github.workspace }}/mac/dist/tasks-mac.zip

41
.github/workflows/build-ubuntu.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: Build-Ubuntu
on: workflow_dispatch
jobs:
ubuntu:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: npm install
run: cd webapp; npm install --no-optional
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.15
- name: apt-get libgtk-3-dev
run: sudo apt-get install libgtk-3-dev
- name: apt-get libwebkit2gtk-4.0-dev
run: sudo apt-get install libwebkit2gtk-4.0-dev
- name: Build Linux server and app
run: make server-linux-package linux-app
- name: Upload server package
uses: actions/upload-artifact@v1
with:
name: octo-linux-amd64.tar.gz
path: ${{ github.workspace }}/dist/octo-linux-amd64.tar.gz
- name: Upload app package
uses: actions/upload-artifact@v1
with:
name: tasks-linux.tar.gz
path: ${{ github.workspace }}/linux/dist/tasks-linux.tar.gz

32
.github/workflows/build-win.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: Build-Windows
on: workflow_dispatch
jobs:
windows:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: win-node-env
run: npm install -g win-node-env
- name: npm install
run: cd webapp; npm install --no-optional
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.15
- name: Build Windows app
run: make win-app
- name: Upload app package
uses: actions/upload-artifact@v1
with:
name: tasks-win.zip
path: ${{ github.workspace }}/win/dist/tasks-win.zip

48
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,48 @@
name: Check-in tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
ci-ubuntu:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: npm install
run: cd webapp; npm install
- name: ESLint
run: cd webapp; npm run check
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.15
- name: Build Linux server
run: make server-linux-package
- name: Copy server binary for Cypress
run: cp bin/linux/octoserver bin/
- name: Upload server package
uses: actions/upload-artifact@v1
with:
name: octo-linux-amd64.tar.gz
path: ${{ github.workspace }}/dist/octo-linux-amd64.tar.gz
- name: Test server
run: make server-test
- name: "Test webapp: Jest"
run: cd webapp; npm run test
- name: "Test webapp: Cypress"
run: "cd webapp; npm run cypress:ci"

3
.gitignore vendored
View File

@ -23,6 +23,7 @@ lib-cov
# Coverage directory used by tools like istanbul
coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
@ -51,4 +52,6 @@ mac/dist
linux/bin
linux/dist
linux/temp
win/temp
win/dist
webapp/cypress/videos

15
.vscode/launch.json vendored
View File

@ -11,7 +11,20 @@
"mode": "debug",
"program": "${workspaceFolder}/server/main",
"cwd": "${workspaceFolder}"
},
},
{
"name": "Go: Test Current File",
"type": "go",
"request": "launch",
"mode": "test",
"remotePath": "",
"port": 8888,
"host": "127.0.0.1",
"program": "${file}",
"env": {},
"args": [],
"showLog": true
},
{
"name": "Attach by Process ID",
"processId": "${command:PickProcess}",

View File

@ -1,4 +1,6 @@
.PHONY: prebuild clean cleanall server server-mac server-linux server-win generate watch-server webapp mac-app win-app linux-app
.PHONY: prebuild clean cleanall server server-mac server-linux server-win server-linux-package generate watch-server webapp mac-app win-app linux-app
PACKAGE_FOLDER = octo
all: server
@ -22,7 +24,17 @@ server-linux:
cd server; env GOOS=linux GOARCH=amd64 go build -o ../bin/linux/octoserver ./main
server-win:
cd server; env GOOS=windows GOARCH=amd64 go build -o ../bin/octoserver.exe ./main
cd server; env GOOS=windows GOARCH=amd64 go build -o ../bin/win/octoserver.exe ./main
server-linux-package: server-linux webapp
rm -rf package
mkdir -p package/${PACKAGE_FOLDER}/bin
cp bin/linux/octoserver package/${PACKAGE_FOLDER}/bin
cp -R webapp/pack package/${PACKAGE_FOLDER}/pack
cp config.json package/${PACKAGE_FOLDER}
mkdir -p dist
cd package && tar -czvf ../dist/octo-linux-amd64.tar.gz ${PACKAGE_FOLDER}
rm -rf package
server-single-user:
cd server; go build -o ../bin/octoserver ./main --single-user
@ -51,7 +63,7 @@ server-lint:
cd server; golangci-lint run -p format -p unused -p complexity -p bugs -p performance -E asciicheck -E depguard -E dogsled -E dupl -E funlen -E gochecknoglobals -E gochecknoinits -E goconst -E gocritic -E godot -E godox -E goerr113 -E goheader -E golint -E gomnd -E gomodguard -E goprintffuncname -E gosimple -E interfacer -E lll -E misspell -E nlreturn -E nolintlint -E stylecheck -E unconvert -E whitespace -E wsl --skip-dirs services/store/sqlstore/migrations/ ./...
server-test:
cd server; go test ./...
cd server; go test -v ./...
server-doc:
cd server; go doc ./...
@ -72,18 +84,22 @@ mac-app: server-mac webapp
cp bin/mac/octoserver mac/resources/bin/octoserver
cp -R webapp/pack mac/resources/pack
mkdir -p mac/temp
xcodebuild archive -workspace mac/Tasks.xcworkspace -scheme Tasks -archivePath mac/temp/tasks.xcarchive
xcodebuild -exportArchive -archivePath mac/temp/tasks.xcarchive -exportPath mac/dist -exportOptionsPlist mac/export.plist
xcodebuild archive -workspace mac/Tasks.xcworkspace -scheme Tasks -archivePath mac/temp/tasks.xcarchive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED="NO" CODE_SIGNING_ALLOWED="NO"
mkdir -p mac/dist
cp -R mac/temp/tasks.xcarchive/Products/Applications/Tasks.app mac/dist/
# xcodebuild -exportArchive -archivePath mac/temp/tasks.xcarchive -exportPath mac/dist -exportOptionsPlist mac/export.plist
cd mac/dist; zip -r tasks-mac.zip Tasks.app
win-app: server-win webapp
cd win; make build
mkdir -p win/dist/bin
cp -R bin/octoserver.exe win/dist/bin
cp -R config.json win/dist
mkdir -p win/dist/webapp
cp -R webapp/pack win/dist/webapp/pack
# cd win/dist; zip -r ../tasks-win.zip .
mkdir -p win/temp/bin
cp -R bin/win/octoserver.exe win/temp/bin
cp -R config.json win/temp
mkdir -p win/temp/webapp
cp -R webapp/pack win/temp/webapp/pack
mkdir -p win/dist
# cd win/temp; tar -acf ../dist/tasks-win.zip .
cd win/temp; powershell "Compress-Archive * ../dist/tasks-win.zip"
linux-app: server-linux webapp
rm -rf linux/temp

View File

@ -4,6 +4,7 @@
"dbtype": "sqlite3",
"dbconfig": "./octo.db",
"postgres_dbconfig": "dbname=octo sslmode=disable",
"test_dbconfig": "file::memory:?cache=shared",
"useSSL": false,
"webpath": "./webapp/pack",
"filespath": "./files",

View File

@ -274,7 +274,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/usr/bin/codesign --force --sign \"$CODE_SIGN_IDENTITY\" -i \"com.qrayon.octoserver\" --entitlement \"$PROJECT_DIR/Tasks/Inherit.entitlements\" \"$BUILD_DIR/$CONFIGURATION/$EXECUTABLE_FOLDER_PATH/../Resources/resources/bin/octoserver\"\n";
shellScript = "# Comment out codesign for now\n# /usr/bin/codesign --force --sign \"$CODE_SIGN_IDENTITY\" -i \"com.qrayon.octoserver\" --entitlement \"$PROJECT_DIR/Tasks/Inherit.entitlements\" \"$BUILD_DIR/$CONFIGURATION/$EXECUTABLE_FOLDER_PATH/../Resources/resources/bin/octoserver\"\n";
};
/* End PBXShellScriptBuildPhase section */
@ -453,9 +453,9 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Tasks/Tasks.entitlements;
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = HFP57A3MYB;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Tasks/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -464,6 +464,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = com.mattermost.Tasks;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Debug;
@ -474,9 +475,9 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Tasks/Tasks.entitlements;
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = HFP57A3MYB;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Tasks/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -485,6 +486,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = com.mattermost.Tasks;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Release;
@ -494,9 +496,9 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = HFP57A3MYB;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
INFOPLIST_FILE = TasksTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -516,9 +518,9 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = HFP57A3MYB;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
INFOPLIST_FILE = TasksTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -537,9 +539,9 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = HFP57A3MYB;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
INFOPLIST_FILE = TasksUITests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -557,9 +559,9 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = HFP57A3MYB;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
INFOPLIST_FILE = TasksUITests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",

View File

@ -59,7 +59,7 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
blocks, err := a.app().GetBlocks(parentID, blockType)
if err != nil {
log.Printf(`ERROR GetBlocks: %v`, r)
log.Printf(`ERROR GetBlocks: %v, REQUEST: %v`, err, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
@ -69,7 +69,7 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
json, err := json.Marshal(blocks)
if err != nil {
log.Printf(`ERROR json.Marshal: %v`, r)
log.Printf(`ERROR json.Marshal: %v, REQUEST: %v`, err, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
@ -131,7 +131,7 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
err = a.app().InsertBlocks(blocks)
if err != nil {
log.Printf(`ERROR: %v`, r)
log.Printf(`ERROR: %v, REQUEST: %v`, err, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
@ -206,7 +206,7 @@ func (a *API) handleDeleteBlock(w http.ResponseWriter, r *http.Request) {
err := a.app().DeleteBlock(blockID)
if err != nil {
log.Printf(`ERROR: %v`, r)
log.Printf(`ERROR: %v, REQUEST: %v`, err, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
@ -236,7 +236,7 @@ func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) {
blocks, err := a.app().GetSubTree(blockID, int(levels))
if err != nil {
log.Printf(`ERROR: %v`, r)
log.Printf(`ERROR: %v, REQUEST: %v`, err, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
@ -245,7 +245,7 @@ func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) {
log.Printf("GetSubTree (%v) blockID: %s, %d result(s)", levels, blockID, len(blocks))
json, err := json.Marshal(blocks)
if err != nil {
log.Printf(`ERROR json.Marshal: %v`, r)
log.Printf(`ERROR json.Marshal: %v, REQUEST: %v`, err, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
@ -257,17 +257,19 @@ func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) {
func (a *API) handleExport(w http.ResponseWriter, r *http.Request) {
blocks, err := a.app().GetAllBlocks()
if err != nil {
log.Printf(`ERROR: %v`, r)
log.Printf(`ERROR: %v, REQUEST: %v`, err, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
}
log.Printf("EXPORT Blocks, %d result(s)", len(blocks))
log.Printf("%d raw block(s)", len(blocks))
blocks = filterOrphanBlocks(blocks)
log.Printf("EXPORT %d filtered block(s)", len(blocks))
json, err := json.Marshal(blocks)
if err != nil {
log.Printf(`ERROR json.Marshal: %v`, r)
log.Printf(`ERROR json.Marshal: %v, REQUEST: %v`, err, r)
errorResponse(w, http.StatusInternalServerError, nil)
return
@ -276,6 +278,50 @@ func (a *API) handleExport(w http.ResponseWriter, r *http.Request) {
jsonBytesResponse(w, http.StatusOK, json)
}
func filterOrphanBlocks(blocks []model.Block) (ret []model.Block) {
queue := make([]model.Block, 0)
var childrenOfBlockWithID = make(map[string]*[]model.Block)
// Build the trees from nodes
for _, block := range blocks {
if len(block.ParentID) == 0 {
// Queue root blocks to process first
queue = append(queue, block)
} else {
siblings := childrenOfBlockWithID[block.ParentID]
if siblings != nil {
*siblings = append(*siblings, block)
} else {
siblings := []model.Block{block}
childrenOfBlockWithID[block.ParentID] = &siblings
}
}
}
// Map the trees to an array, which skips orphaned nodes
blocks = make([]model.Block, 0)
for len(queue) > 0 {
block := queue[0]
queue = queue[1:] // dequeue
blocks = append(blocks, block)
children := childrenOfBlockWithID[block.ID]
if children != nil {
queue = append(queue, (*children)...)
}
}
return blocks
}
func arrayContainsBlockWithID(array []model.Block, blockID string) bool {
for _, item := range array {
if item.ID == blockID {
return true
}
}
return false
}
func (a *API) handleImport(w http.ResponseWriter, r *http.Request) {
requestBody, err := ioutil.ReadAll(r.Body)
if err != nil {
@ -305,7 +351,7 @@ func (a *API) handleImport(w http.ResponseWriter, r *http.Request) {
err = a.app().InsertBlocks(blocks)
if err != nil {
log.Printf(`ERROR: %v`, err)
log.Printf(`ERROR: %v, REQUEST: %v`, err, r)
errorResponse(w, http.StatusInternalServerError, nil)
return

View File

@ -13,28 +13,34 @@ func TestGetBlocks(t *testing.T) {
th := SetupTestHelper().InitBasic()
defer th.TearDown()
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
initialCount := len(blocks)
blockID1 := utils.CreateGUID()
blockID2 := utils.CreateGUID()
newBlocks := []model.Block{
{
ID: blockID1,
RootID: blockID1,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
},
{
ID: blockID2,
RootID: blockID2,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
},
}
_, resp := th.Client.InsertBlocks(newBlocks)
_, resp = th.Client.InsertBlocks(newBlocks)
require.NoError(t, resp.Error)
blocks, resp := th.Client.GetBlocks()
blocks, resp = th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 2)
require.Len(t, blocks, initialCount+2)
blockIDs := make([]string, len(blocks))
for i, b := range blocks {
@ -48,6 +54,10 @@ func TestPostBlock(t *testing.T) {
th := SetupTestHelper().InitBasic()
defer th.TearDown()
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
initialCount := len(blocks)
blockID1 := utils.CreateGUID()
blockID2 := utils.CreateGUID()
blockID3 := utils.CreateGUID()
@ -55,6 +65,7 @@ func TestPostBlock(t *testing.T) {
t.Run("Create a single block", func(t *testing.T) {
block := model.Block{
ID: blockID1,
RootID: blockID1,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
@ -66,20 +77,27 @@ func TestPostBlock(t *testing.T) {
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 1)
require.Equal(t, blockID1, blocks[0].ID)
require.Len(t, blocks, initialCount+1)
blockIDs := make([]string, len(blocks))
for i, b := range blocks {
blockIDs[i] = b.ID
}
require.Contains(t, blockIDs, blockID1)
})
t.Run("Create a couple of blocks in the same call", func(t *testing.T) {
newBlocks := []model.Block{
{
ID: blockID2,
RootID: blockID2,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
},
{
ID: blockID3,
RootID: blockID3,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
@ -91,7 +109,7 @@ func TestPostBlock(t *testing.T) {
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 3)
require.Len(t, blocks, initialCount+3)
blockIDs := make([]string, len(blocks))
for i, b := range blocks {
@ -105,6 +123,7 @@ func TestPostBlock(t *testing.T) {
t.Run("Update a block", func(t *testing.T) {
block := model.Block{
ID: blockID1,
RootID: blockID1,
CreateAt: 1,
UpdateAt: 20,
Type: "board",
@ -116,7 +135,7 @@ func TestPostBlock(t *testing.T) {
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 3)
require.Len(t, blocks, initialCount+3)
var updatedBlock model.Block
for _, b := range blocks {
@ -133,10 +152,15 @@ func TestDeleteBlock(t *testing.T) {
th := SetupTestHelper().InitBasic()
defer th.TearDown()
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
initialCount := len(blocks)
blockID := utils.CreateGUID()
t.Run("Create a block", func(t *testing.T) {
block := model.Block{
ID: blockID,
RootID: blockID,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
@ -148,8 +172,14 @@ func TestDeleteBlock(t *testing.T) {
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 1)
require.Equal(t, blockID, blocks[0].ID)
require.Len(t, blocks, initialCount+1)
blockIDs := make([]string, len(blocks))
for i, b := range blocks {
blockIDs[i] = b.ID
}
require.Contains(t, blockIDs, blockID)
})
t.Run("Delete a block", func(t *testing.T) {
@ -158,7 +188,7 @@ func TestDeleteBlock(t *testing.T) {
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 0)
require.Len(t, blocks, initialCount)
})
}
@ -166,6 +196,10 @@ func TestGetSubtree(t *testing.T) {
th := SetupTestHelper().InitBasic()
defer th.TearDown()
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
initialCount := len(blocks)
parentBlockID := utils.CreateGUID()
childBlockID1 := utils.CreateGUID()
childBlockID2 := utils.CreateGUID()
@ -173,12 +207,14 @@ func TestGetSubtree(t *testing.T) {
newBlocks := []model.Block{
{
ID: parentBlockID,
RootID: parentBlockID,
CreateAt: 1,
UpdateAt: 1,
Type: "board",
},
{
ID: childBlockID1,
RootID: parentBlockID,
ParentID: parentBlockID,
CreateAt: 2,
UpdateAt: 2,
@ -186,6 +222,7 @@ func TestGetSubtree(t *testing.T) {
},
{
ID: childBlockID2,
RootID: parentBlockID,
ParentID: parentBlockID,
CreateAt: 2,
UpdateAt: 2,
@ -198,8 +235,13 @@ func TestGetSubtree(t *testing.T) {
blocks, resp := th.Client.GetBlocks()
require.NoError(t, resp.Error)
require.Len(t, blocks, 1)
require.Equal(t, parentBlockID, blocks[0].ID)
require.Len(t, blocks, initialCount+1) // GetBlocks returns root blocks (null ParentID)
blockIDs := make([]string, len(blocks))
for i, b := range blocks {
blockIDs[i] = b.ID
}
require.Contains(t, blockIDs, parentBlockID)
})
t.Run("Get subtree for parent ID", func(t *testing.T) {

View File

@ -1,7 +1,9 @@
package integrationtests
import (
"log"
"net/http"
"time"
"github.com/mattermost/mattermost-octo-tasks/server/client"
"github.com/mattermost/mattermost-octo-tasks/server/server"
@ -43,6 +45,29 @@ func (th *TestHelper) InitBasic() *TestHelper {
}
}()
for {
URL := th.Server.Config().ServerRoot
log.Printf("Polling server at %v", URL)
resp, err := http.Get(URL)
if err != nil {
log.Println("Polling failed:", err)
time.Sleep(100 * time.Millisecond)
continue
}
resp.Body.Close()
// Currently returns 404
// if resp.StatusCode != http.StatusOK {
// log.Println("Not OK:", resp.StatusCode)
// continue
// }
// Reached this point: server is up and running!
log.Println("Server ping OK, statusCode:", resp.StatusCode)
break
}
return th
}

View File

@ -19,6 +19,13 @@ type Block struct {
DeleteAt int64 `json:"deleteAt"`
}
// Archive is an import / export archive
type Archive struct {
Version int64 `json:"version"`
Date int64 `json:"date"`
Blocks []Block `json:"blocks"`
}
func BlocksFromJSON(data io.Reader) []Block {
var blocks []Block
json.NewDecoder(data).Decode(&blocks)

View File

@ -14,10 +14,11 @@ func TestInsertBlock(t *testing.T) {
blocks, err := store.GetAllBlocks()
require.NoError(t, err)
require.Empty(t, blocks)
initialCount := len(blocks)
block := model.Block{
ID: "id-test",
ID: "id-test",
RootID: "id-test",
}
err = store.InsertBlock(block)
@ -25,7 +26,7 @@ func TestInsertBlock(t *testing.T) {
blocks, err = store.GetAllBlocks()
require.NoError(t, err)
require.Len(t, blocks, 1)
require.Len(t, blocks, initialCount+1)
// Wait for not colliding the ID+insert_at key
time.Sleep(1 * time.Millisecond)
@ -34,7 +35,7 @@ func TestInsertBlock(t *testing.T) {
blocks, err = store.GetAllBlocks()
require.NoError(t, err)
require.Empty(t, blocks)
require.Len(t, blocks, initialCount)
}
func TestGetSubTree2(t *testing.T) {
@ -43,36 +44,46 @@ func TestGetSubTree2(t *testing.T) {
blocks, err := store.GetAllBlocks()
require.NoError(t, err)
require.Empty(t, blocks)
initialCount := len(blocks)
blocksToInsert := []model.Block{
model.Block{
ID: "parent",
ID: "parent",
RootID: "parent",
},
model.Block{
ID: "child1",
RootID: "parent",
ParentID: "parent",
},
model.Block{
ID: "child2",
RootID: "parent",
ParentID: "parent",
},
model.Block{
ID: "grandchild1",
RootID: "parent",
ParentID: "child1",
},
model.Block{
ID: "grandchild2",
RootID: "parent",
ParentID: "child2",
},
model.Block{
ID: "greatgrandchild1",
RootID: "parent",
ParentID: "grandchild1",
},
}
InsertBlocks(t, store, blocksToInsert)
blocks, err = store.GetAllBlocks()
require.NoError(t, err)
require.Len(t, blocks, initialCount+6)
blocks, err = store.GetSubTree2("parent")
require.NoError(t, err)
require.Len(t, blocks, 3)
@ -86,7 +97,7 @@ func TestGetSubTree2(t *testing.T) {
blocks, err = store.GetAllBlocks()
require.NoError(t, err)
require.Empty(t, blocks)
require.Len(t, blocks, initialCount)
}
func TestGetSubTree3(t *testing.T) {
@ -95,36 +106,46 @@ func TestGetSubTree3(t *testing.T) {
blocks, err := store.GetAllBlocks()
require.NoError(t, err)
require.Empty(t, blocks)
initialCount := len(blocks)
blocksToInsert := []model.Block{
model.Block{
ID: "parent",
ID: "parent",
RootID: "parent",
},
model.Block{
ID: "child1",
RootID: "parent",
ParentID: "parent",
},
model.Block{
ID: "child2",
RootID: "parent",
ParentID: "parent",
},
model.Block{
ID: "grandchild1",
RootID: "parent",
ParentID: "child1",
},
model.Block{
ID: "grandchild2",
RootID: "parent",
ParentID: "child2",
},
model.Block{
ID: "greatgrandchild1",
RootID: "parent",
ParentID: "grandchild1",
},
}
InsertBlocks(t, store, blocksToInsert)
blocks, err = store.GetAllBlocks()
require.NoError(t, err)
require.Len(t, blocks, initialCount+6)
blocks, err = store.GetSubTree3("parent")
require.NoError(t, err)
require.Len(t, blocks, 5)
@ -140,5 +161,5 @@ func TestGetSubTree3(t *testing.T) {
blocks, err = store.GetAllBlocks()
require.NoError(t, err)
require.Empty(t, blocks)
require.Len(t, blocks, initialCount)
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
//go:generate go-bindata -prefix templates/ -pkg initializations -o bindata.go ./templates
package initializations

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,63 @@
package sqlstore
import (
"encoding/json"
"log"
"github.com/mattermost/mattermost-octo-tasks/server/model"
"github.com/mattermost/mattermost-octo-tasks/server/services/store/sqlstore/initializations"
)
// InitializeTemplates imports default templates if the blocks table is empty
func (s *SQLStore) InitializeTemplates() error {
isNeeded, err := s.isInitializationNeeded()
if err != nil {
return err
}
if isNeeded {
return s.importInitialTemplates()
}
return nil
}
func (s *SQLStore) importInitialTemplates() error {
log.Printf("importInitialTemplates")
blocksJSON := initializations.MustAsset("templates.json")
var archive model.Archive
err := json.Unmarshal(blocksJSON, &archive)
if err != nil {
return err
}
log.Printf("Inserting %d blocks", len(archive.Blocks))
for _, block := range archive.Blocks {
// log.Printf("\t%v %v %v", block.ID, block.Type, block.Title)
err := s.InsertBlock(block)
if err != nil {
return err
}
}
return nil
}
// isInitializationNeeded returns true if the blocks table is empty
func (s *SQLStore) isInitializationNeeded() (bool, error) {
query := s.getQueryBuilder().
Select("count(*)").
From("blocks")
row := query.QueryRow()
var count int
err := row.Scan(&count)
if err != nil {
log.Fatal(err)
return false, err
}
return (count == 0), nil
}

View File

@ -44,6 +44,13 @@ func New(dbType, connectionString string) (*SQLStore, error) {
return nil, err
}
err = store.InitializeTemplates()
if err != nil {
log.Printf(`InitializeTemplates failed: %v`, err)
return nil, err
}
return store, nil
}

View File

@ -58,7 +58,8 @@
}
],
"react/no-string-refs": 2,
"no-only-tests/no-only-tests": ["error", {"focus": ["only", "skip"]}]
"no-only-tests/no-only-tests": ["error", {"focus": ["only", "skip"]}],
"max-nested-callbacks": ["error", {"max": 5}]
},
"overrides": [
{

5
webapp/cypress.json Normal file
View File

@ -0,0 +1,5 @@
{
"chromeWebSecurity": false,
"baseUrl": "http://localhost:8088",
"video": false
}

View File

@ -0,0 +1,11 @@
{
"serverRoot": "http://localhost:8088",
"port": 8088,
"dbtype": "sqlite3",
"dbconfig": "file::memory:?cache=shared",
"useSSL": false,
"webpath": "../pack",
"filespath": "../../files",
"telemetry": true,
"webhook_update": []
}

View File

@ -0,0 +1,118 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-nested-callbacks */
/// <reference types="Cypress" />
describe('Create and delete board / card', () => {
const timestamp = new Date().toLocaleString();
const boardTitle = `Test Board (${timestamp})`;
const cardTitle = `Test Card (${timestamp})`;
it('Can create and delete a board and card', () => {
cy.visit('/');
cy.contains('+ Add Board').click({force: true});
cy.contains('Empty board').click({force: true});
cy.get('.BoardComponent').should('exist');
});
it('Can set the board title', () => {
// Board title
cy.get('.ViewTitle>.Editable.title').
type(boardTitle).
type('{enter}').
should('have.value', boardTitle);
});
it('Can rename the board view', () => {
// Rename board view
const boardViewTitle = `Test board (${timestamp})`;
cy.get('.ViewHeader').
contains('.octo-editable', 'Board view').
clear().
type(boardViewTitle).
type('{esc}');
cy.get('.ViewHeader').
contains('.octo-editable', boardViewTitle).
should('exist');
});
it('Can create a card', () => {
// Create card
cy.get('.ViewHeader').contains('New').click();
cy.get('.CardDetail').should('exist');
});
it('Can set the card title', () => {
// Card title
cy.get('.CardDetail>.Editable.title').
type(cardTitle).
type('{enter}').
should('have.value', cardTitle);
// Close card
cy.get('.Dialog.dialog-back').click({force: true});
});
it('Can create a table view', () => {
// Create table view
// cy.intercept('POST', '/api/v1/blocks').as('insertBlocks');
cy.get('.ViewHeader').get('.DropdownIcon').first().parent().click();
cy.get('.ViewHeader').contains('Add View').click();
cy.get('.ViewHeader').contains('Add View').click();
cy.get('.ViewHeader').contains('Add View').parent().contains('Table').click();
// cy.wait('@insertBlocks');
// Wait for round-trip to complete and DOM to update
cy.contains('.octo-editable', 'Table view').should('exist');
// Card should exist in table
cy.get(`.TableRow [value='${cardTitle}']`).should('exist');
});
it('Can rename the table view', () => {
// Rename table view
const tableViewTitle = `Test table (${timestamp})`;
cy.get('.ViewHeader').
contains('.octo-editable', 'Table view').
clear().
type(tableViewTitle).
type('{esc}');
cy.get('.ViewHeader').
contains('.octo-editable', tableViewTitle).
should('exist');
});
it('Can sort the table', () => {
// Sort
cy.get('.ViewHeader').contains('Sort').click();
cy.get('.ViewHeader').contains('Sort').parent().contains('Name').click();
});
it('Can view the readonly board', () => {
cy.url().then((url) => {
const readonlyUrl = url + '&r=1';
cy.visit(readonlyUrl);
cy.get('.ViewTitle>.Editable.title').should('have.attr', 'readonly');
cy.visit(url);
});
});
it('Can delete the board', () => {
// Delete board
cy.get('.Sidebar .octo-sidebar-list').
contains(boardTitle).first().
next().
find('.Button.IconButton').
click({force: true});
cy.contains('Delete board').click({force: true});
// // Board should not exist
cy.contains(boardTitle).should('not.exist');
});
});

View File

@ -0,0 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
describe('Load homepage', () => {
it('Can load homepage', () => {
cy.visit('/');
cy.get('div#octo-tasks-app').should('exist');
});
});

View File

@ -0,0 +1,21 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

View File

@ -0,0 +1,25 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

View File

@ -0,0 +1,20 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["cypress"]
},
"include": [
"**/*.ts"
]
}

View File

@ -4,56 +4,86 @@
"BoardCard.untitled": "Untitled",
"BoardComponent.add-a-group": "+ Add a group",
"BoardComponent.delete": "Delete",
"BoardComponent.hidden-columns": "Hidden Columns",
"BoardComponent.hidden-columns": "Hidden columns",
"BoardComponent.hide": "Hide",
"BoardComponent.neww": "+ New",
"BoardComponent.no-property": "No {property}",
"BoardComponent.no-property-title": "Items with an empty {property} property will go here. This column cannot be removed.",
"BoardComponent.show": "Show",
"CardDetail.add-content": "Add content",
"CardDetail.add-icon": "Add Icon",
"CardDetail.add-icon": "Add icon",
"CardDetail.add-property": "+ Add a property",
"CardDetail.addCardText": "add card text",
"CardDetail.image": "Image",
"CardDetail.new-comment-placeholder": "Add a comment...",
"CardDetail.text": "Text",
"CardDialog.editing-template": "You're editing a template",
"CardDialog.nocard": "This card doesn't exist or is inaccessible",
"Comment.delete": "Delete",
"CommentsList.send": "Send",
"ContentBlock.Delete": "Delete",
"ContentBlock.DeleteAction": "delete",
"ContentBlock.Text": "Text",
"ContentBlock.addDivider": "add divider",
"ContentBlock.addImage": "add image",
"ContentBlock.addText": "add text",
"ContentBlock.divider": "Divider",
"ContentBlock.editCardText": "edit card text",
"ContentBlock.editText": "Edit text...",
"ContentBlock.insertAbove": "Insert above",
"ContentBlock.moveDown": "Move down",
"ContentBlock.moveUp": "Move up",
"Filter.includes": "includes",
"Filter.is-empty": "is empty",
"Filter.is-not-empty": "is not empty",
"Filter.not-includes": "doesn't include",
"FilterComponent.add-filter": "+ Add Filter",
"FilterComponent.add-filter": "+ Add filter",
"FilterComponent.delete": "Delete",
"Mutator.duplicate-board": "duplicate board",
"Mutator.new-board-from-template": "new board from template",
"Mutator.new-card-from-template": "new card from template",
"Mutator.new-template-from-board": "new template from board",
"Mutator.new-template-from-card": "new template from card",
"PropertyMenu.changeType": "Change property type",
"PropertyMenu.typeTitle": "Type",
"PropertyType.Checkbox": "Checkbox",
"PropertyType.CreatedBy": "Created By",
"PropertyType.CreatedTime": "Created Time",
"PropertyType.Email": "Email",
"PropertyType.File": "File or Media",
"PropertyType.MultiSelect": "Multi Select",
"PropertyType.Number": "Number",
"PropertyType.Person": "Person",
"PropertyType.Phone": "Phone",
"PropertyType.Select": "Select",
"PropertyType.Text": "Text",
"PropertyType.URL": "URL",
"PropertyType.UpdatedBy": "Updated By",
"PropertyType.UpdatedTime": "Updated Time",
"Sidebar.add-board": "+ Add Board",
"Sidebar.add-template": "+ New template",
"Sidebar.dark-theme": "Dark Theme",
"Sidebar.delete-board": "Delete Board",
"Sidebar.dark-theme": "Dark theme",
"Sidebar.default-theme": "Default theme",
"Sidebar.delete-board": "Delete board",
"Sidebar.delete-template": "Delete",
"Sidebar.duplicate-board": "Duplicate Board",
"Sidebar.duplicate-board": "Duplicate board",
"Sidebar.edit-template": "Edit",
"Sidebar.empty-board": "Empty board",
"Sidebar.english": "English",
"Sidebar.export-archive": "Export Archive",
"Sidebar.import-archive": "Import Archive",
"Sidebar.light-theme": "Light Theme",
"Sidebar.mattermost-theme": "Mattermost Theme",
"Sidebar.export-archive": "Export archive",
"Sidebar.import-archive": "Import archive",
"Sidebar.light-theme": "Light theme",
"Sidebar.no-views-in-board": "No pages inside",
"Sidebar.select-a-template": "Select a template",
"Sidebar.set-language": "Set Language",
"Sidebar.set-theme": "Set Theme",
"Sidebar.set-language": "Set language",
"Sidebar.set-theme": "Set theme",
"Sidebar.settings": "Settings",
"Sidebar.spanish": "Spanish",
"Sidebar.template-from-board": "New template from board",
"Sidebar.untitled": "Untitled",
"Sidebar.untitled-board": "(Untitled Board)",
"Sidebar.untitled-view": "(Untitled View)",
"TableComponent.add-icon": "Add Icon",
"TableComponent.add-icon": "Add icon",
"TableComponent.name": "Name",
"TableComponent.plus-new": "+ New",
"TableHeaderMenu.delete": "Delete",
@ -64,13 +94,13 @@
"TableHeaderMenu.sort-ascending": "Sort ascending",
"TableHeaderMenu.sort-descending": "Sort descending",
"TableRow.open": "Open",
"View.NewBoardTitle": "Board View",
"View.NewTableTitle": "Table View",
"View.NewBoardTitle": "Board view",
"View.NewTableTitle": "Table view",
"ViewHeader.add-template": "+ New template",
"ViewHeader.delete-template": "Delete",
"ViewHeader.edit-template": "Edit",
"ViewHeader.empty-card": "Empty card",
"ViewHeader.export-board-archive": "Export Board Archive",
"ViewHeader.export-board-archive": "Export board archive",
"ViewHeader.export-csv": "Export to CSV",
"ViewHeader.filter": "Filter",
"ViewHeader.group-by": "Group by {property}",
@ -86,10 +116,10 @@
"ViewHeader.test-randomize-icons": "TEST: Randomize icons",
"ViewHeader.untitled": "Untitled",
"ViewTitle.hide-description": "hide description",
"ViewTitle.pick-icon": "Pick Icon",
"ViewTitle.pick-icon": "Pick icon",
"ViewTitle.random-icon": "Random",
"ViewTitle.remove-icon": "Remove Icon",
"ViewTitle.remove-icon": "Remove icon",
"ViewTitle.show-description": "show description",
"ViewTitle.untitled-board": "Untitled Board",
"ViewTitle.untitled-board": "Untitled board",
"WorkspaceComponent.editing-board-template": "You're editing a board template"
}

View File

@ -35,7 +35,6 @@
"Sidebar.export-archive": "Exportar Archivo",
"Sidebar.import-archive": "Importar Archivo",
"Sidebar.light-theme": "Apariencia Clara",
"Sidebar.mattermost-theme": "Aparencia Mattermost",
"Sidebar.no-views-in-board": "No hay páginas dentro",
"Sidebar.set-language": "Establecer idioma",
"Sidebar.set-theme": "Establecer apariencia",

1515
webapp/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,15 @@
"test": "jest",
"check": "eslint --ext .tsx,.ts . --quiet --cache",
"fix": "eslint --ext .tsx,.ts . --quiet --fix --cache",
"i18n-extract": "formatjs extract src/**/*.tsx src/**/*.ts --out-file i18n/tmp.json; formatjs compile i18n/tmp.json --out-file i18n/en.json; rm i18n/tmp.json"
"i18n-extract": "formatjs extract src/**/*.tsx src/**/*.ts --out-file i18n/tmp.json; formatjs compile i18n/tmp.json --out-file i18n/en.json; rm i18n/tmp.json",
"runserver-test": "cd cypress && ../../bin/octoserver",
"cypress:ci": "start-server-and-test runserver-test http://localhost:8088 cypress:run",
"cypress:run": "cypress run",
"cypress:run:chrome": "cypress run --browser chrome",
"cypress:run:firefox": "cypress run --browser firefox",
"cypress:run:edge": "cypress run --browser edge",
"cypress:run:electron": "cypress run --browser electron",
"cypress:open": "cypress open"
},
"dependencies": {
"emoji-mart": "^3.0.0",
@ -24,12 +32,20 @@
"react-simplemde-editor": "^4.1.3"
},
"jest": {
"globals": {
"ts-jest": {
"tsConfig": "./src/tsconfig.json"
}
},
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"collectCoverage": true,
"collectCoverageFrom": ["src/**/*.{ts,tsx,js,jsx}"]
},
"collectCoverage": true,
"collectCoverageFrom": [
"src/**/*.{ts,tsx,js,jsx}",
"!src/test/**"
]
},
"devDependencies": {
"@formatjs/cli": "^2.13.2",
"@formatjs/ts-transformer": "^2.11.3",
@ -60,9 +76,11 @@
"eslint-plugin-react": "7.20.6",
"file-loader": "^6.1.0",
"html-webpack-plugin": "^4.5.0",
"isomorphic-fetch": "^3.0.0",
"jest": "^26.5.3",
"sass": "^1.27.0",
"sass-loader": "^10.0.2",
"start-server-and-test": "^1.11.7",
"style-loader": "^1.3.0",
"terser-webpack-plugin": "^4.1.0",
"ts-jest": "^26.4.1",
@ -71,5 +89,8 @@
"webpack": "^4.44.1",
"webpack-cli": "^3.3.12",
"webpack-merge": "^5.1.2"
},
"optionalDependencies": {
"cypress": "^6.2.1"
}
}

View File

@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {TestBlockFactory} from '../test/testBlockFactory'
import {MutableBoard} from './board'
import {MutableBoardView} from './boardView'
import {MutableCard} from './card'
import {MutableCommentBlock} from './commentBlock'
import {MutableDividerBlock} from './dividerBlock'
import {MutableImageBlock} from './imageBlock'
import {MutableTextBlock} from './textBlock'
test('block: clone board', async () => {
const boardA = TestBlockFactory.createBoard()
boardA.isTemplate = true
const boardB = new MutableBoard(boardA)
expect(boardB).toEqual(boardA)
expect(boardB.icon).toBe(boardA.icon)
expect(boardB.isTemplate).toBe(boardA.isTemplate)
expect(boardB.description).toBe(boardA.description)
expect(boardB.showDescription).toBe(boardA.showDescription)
})
test('block: clone view', async () => {
const viewA = TestBlockFactory.createBoardView()
const viewB = new MutableBoardView(viewA)
expect(viewB).toEqual(viewA)
expect(viewB.groupById).toBe(viewA.groupById)
expect(viewB.hiddenOptionIds).toEqual(viewA.hiddenOptionIds)
expect(viewB.visiblePropertyIds).toEqual(viewA.visiblePropertyIds)
expect(viewB.visibleOptionIds).toEqual(viewA.visibleOptionIds)
expect(viewB.filter).toEqual(viewA.filter)
expect(viewB.sortOptions).toEqual(viewA.sortOptions)
expect(viewB.cardOrder).toEqual(viewA.cardOrder)
expect(viewB.columnWidths).toEqual(viewA.columnWidths)
})
test('block: clone card', async () => {
const cardA = TestBlockFactory.createCard()
cardA.isTemplate = true
const cardB = new MutableCard(cardA)
expect(cardB).toEqual(cardA)
expect(cardB.icon).toBe(cardA.icon)
expect(cardB.isTemplate).toBe(cardA.isTemplate)
expect(cardB.contentOrder).toEqual(cardA.contentOrder)
})
test('block: clone comment', async () => {
const card = TestBlockFactory.createCard()
const blockA = TestBlockFactory.createComment(card)
const blockB = new MutableCommentBlock(blockA)
expect(blockB).toEqual(blockA)
})
test('block: clone text', async () => {
const card = TestBlockFactory.createCard()
const blockA = TestBlockFactory.createText(card)
const blockB = new MutableTextBlock(blockA)
expect(blockB).toEqual(blockA)
})
test('block: clone image', async () => {
const card = TestBlockFactory.createCard()
const blockA = TestBlockFactory.createImage(card)
const blockB = new MutableImageBlock(blockA)
expect(blockB).toEqual(blockA)
expect(blockB.url.length).toBeGreaterThan(0)
expect(blockB.url).toEqual(blockA.url)
})
test('block: clone divider', async () => {
const card = TestBlockFactory.createCard()
const blockA = TestBlockFactory.createDivider(card)
const blockB = new MutableDividerBlock(blockA)
expect(blockB).toEqual(blockA)
})

View File

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {IBlock} from '../blocks/block'
import {FilterGroup} from '../filterGroup'
import {Utils} from '../utils'
import {MutableBlock} from './block'
@ -18,6 +19,8 @@ interface BoardView extends IBlock {
readonly filter: FilterGroup
readonly cardOrder: readonly string[]
readonly columnWidths: Readonly<Record<string, number>>
duplicate(): MutableBoardView
}
class MutableBoardView extends MutableBlock implements BoardView {
@ -101,6 +104,12 @@ class MutableBoardView extends MutableBlock implements BoardView {
this.viewType = 'board'
}
}
duplicate(): MutableBoardView {
const view = new MutableBoardView(this)
view.id = Utils.createGuid()
return view
}
}
export {BoardView, MutableBoardView, IViewType, ISortOption}

View File

@ -9,6 +9,8 @@ interface Card extends IBlock {
readonly icon: string
readonly isTemplate: boolean
readonly properties: Readonly<Record<string, string>>
readonly contentOrder: readonly string[]
duplicate(): MutableCard
}
@ -34,12 +36,20 @@ class MutableCard extends MutableBlock {
this.fields.properties = value
}
get contentOrder(): string[] {
return this.fields.contentOrder
}
set contentOrder(value: string[]) {
this.fields.contentOrder = value
}
constructor(block: any = {}) {
super(block)
this.type = 'card'
this.icon = block.fields?.icon || ''
this.properties = {...(block.fields?.properties || {})}
this.contentOrder = block.fields?.contentOrder?.slice() || []
}
duplicate(): MutableCard {

View File

@ -0,0 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IBlock, MutableBlock} from './block'
type IContentBlock = IBlock
class MutableContentBlock extends MutableBlock implements IContentBlock {
}
export {IContentBlock, MutableContentBlock}

View File

@ -1,10 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IOrderedBlock, MutableOrderedBlock} from './orderedBlock'
import {IContentBlock, MutableContentBlock} from './contentBlock'
type DividerBlock = IOrderedBlock
type DividerBlock = IContentBlock
class MutableDividerBlock extends MutableOrderedBlock {
class MutableDividerBlock extends MutableContentBlock {
constructor(block: any = {}) {
super(block)
this.type = 'divider'

View File

@ -1,12 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IOrderedBlock, MutableOrderedBlock} from './orderedBlock'
import {IContentBlock, MutableContentBlock} from './contentBlock'
interface ImageBlock extends IOrderedBlock {
interface ImageBlock extends IContentBlock {
readonly url: string
}
class MutableImageBlock extends MutableOrderedBlock implements IOrderedBlock {
class MutableImageBlock extends MutableContentBlock implements IContentBlock {
get url(): string {
return this.fields.url as string
}

View File

@ -1,25 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IBlock} from '../blocks/block'
import {MutableBlock} from './block'
interface IOrderedBlock extends IBlock {
readonly order: number
}
class MutableOrderedBlock extends MutableBlock 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.order = block.fields?.order || 0
}
}
export {IOrderedBlock, MutableOrderedBlock}

View File

@ -1,10 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IOrderedBlock, MutableOrderedBlock} from './orderedBlock'
import {IContentBlock, MutableContentBlock} from './contentBlock'
type TextBlock = IOrderedBlock
type TextBlock = IContentBlock
class MutableTextBlock extends MutableOrderedBlock {
class MutableTextBlock extends MutableContentBlock {
constructor(block: any = {}) {
super(block)
this.type = 'text'

View File

@ -7,7 +7,7 @@
border-radius: 5px;
align-items: center;
justify-content: center;
&:hover {
&:not(.readonly):hover {
background-color: rgba(var(--main-fg), 0.1);
}
&.size-s {

View File

@ -18,6 +18,7 @@ type Props = {
block: Board|Card
size?: 's' | 'm' | 'l'
intl: IntlShape
readonly?: boolean
}
class BlockIconSelector extends React.Component<Props> {
@ -41,10 +42,17 @@ class BlockIconSelector extends React.Component<Props> {
if (!block.icon) {
return null
}
let className = `octo-icon size-${size}`
if (this.props.readonly) {
className += ' readonly'
}
const iconElement = <div className={className}><span>{block.icon}</span></div>
return (
<div className='BlockIconSelector'>
{this.props.readonly && iconElement}
{!this.props.readonly &&
<MenuWrapper>
<div className={`octo-icon size-${size}`}><span>{block.icon}</span></div>
{iconElement}
<Menu>
<Menu.Text
id='random'
@ -55,18 +63,19 @@ class BlockIconSelector extends React.Component<Props> {
<Menu.SubMenu
id='pick'
icon={<EmojiIcon/>}
name={intl.formatMessage({id: 'ViewTitle.pick-icon', defaultMessage: 'Pick Icon'})}
name={intl.formatMessage({id: 'ViewTitle.pick-icon', defaultMessage: 'Pick icon'})}
>
<EmojiPicker onSelect={this.onSelectEmoji}/>
</Menu.SubMenu>
<Menu.Text
id='remove'
icon={<DeleteIcon/>}
name={intl.formatMessage({id: 'ViewTitle.remove-icon', defaultMessage: 'Remove Icon'})}
name={intl.formatMessage({id: 'ViewTitle.remove-icon', defaultMessage: 'Remove icon'})}
onClick={() => mutator.changeIcon(block, '', 'remove icon')}
/>
</Menu>
</MenuWrapper>
}
</div>
)
}

View File

@ -26,6 +26,7 @@ type BoardCardProps = {
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void
onDrop: (e: React.DragEvent<HTMLDivElement>) => void
intl: IntlShape
readonly: boolean
}
type BoardCardState = {
@ -54,7 +55,7 @@ class BoardCard extends React.Component<BoardCardProps, BoardCardState> {
const element = (
<div
className={className}
draggable={true}
draggable={!this.props.readonly}
style={{opacity: this.state.isDragged ? 0.5 : 1}}
onClick={this.props.onClick}
onDragStart={(e) => {
@ -86,28 +87,30 @@ class BoardCard extends React.Component<BoardCardProps, BoardCardState> {
}
}}
>
<MenuWrapper
className='optionsMenu'
stopPropagationOnToggle={true}
>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='left'>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'BoardCard.delete', defaultMessage: 'Delete'})}
onClick={() => mutator.deleteBlock(card, 'delete card')}
/>
<Menu.Text
icon={<DuplicateIcon/>}
id='duplicate'
name={intl.formatMessage({id: 'BoardCard.duplicate', defaultMessage: 'Duplicate'})}
onClick={() => {
mutator.duplicateCard(card.id)
}}
/>
</Menu>
</MenuWrapper>
{!this.props.readonly &&
<MenuWrapper
className='optionsMenu'
stopPropagationOnToggle={true}
>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='left'>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'BoardCard.delete', defaultMessage: 'Delete'})}
onClick={() => mutator.deleteBlock(card, 'delete card')}
/>
<Menu.Text
icon={<DuplicateIcon/>}
id='duplicate'
name={intl.formatMessage({id: 'BoardCard.duplicate', defaultMessage: 'Duplicate'})}
onClick={() => {
mutator.duplicateCard(card.id)
}}
/>
</Menu>
</MenuWrapper>
}
<div className='octo-icontitle'>
{ card.icon ? <div className='octo-icon'>{card.icon}</div> : undefined }

View File

@ -36,6 +36,7 @@ type Props = {
showView: (id: string) => void
setSearchText: (text?: string) => void
intl: IntlShape
readonly: boolean
}
type State = {
@ -72,6 +73,7 @@ class BoardComponent extends React.Component<Props, State> {
}
componentDidMount(): void {
this.showCardInUrl()
document.addEventListener('keydown', this.keydownHandler)
}
@ -99,6 +101,14 @@ class BoardComponent extends React.Component<Props, State> {
}
}
private showCardInUrl() {
const queryString = new URLSearchParams(window.location.search)
const cardId = queryString.get('c') || undefined
if (cardId !== this.state.shownCardId) {
this.setState({shownCardId: cardId})
}
}
render(): JSX.Element {
const {boardTree, showView} = this.props
const {groupByProperty} = boardTree
@ -129,8 +139,9 @@ class BoardComponent extends React.Component<Props, State> {
key={this.state.shownCardId}
boardTree={boardTree}
cardId={this.state.shownCardId}
onClose={() => this.setState({shownCardId: undefined})}
showCard={(cardId) => this.setState({shownCardId: cardId})}
onClose={() => this.showCard(undefined)}
showCard={(cardId) => this.showCard(cardId)}
readonly={this.props.readonly}
/>
</RootPortal>}
@ -138,6 +149,7 @@ class BoardComponent extends React.Component<Props, State> {
<ViewTitle
key={board.id + board.title}
board={board}
readonly={this.props.readonly}
/>
<div className='octo-board'>
@ -150,6 +162,7 @@ class BoardComponent extends React.Component<Props, State> {
addCardTemplate={this.addCardTemplate}
editCardTemplate={this.editCardTemplate}
withGroupBy={true}
readonly={this.props.readonly}
/>
<div
className='octo-board-header'
@ -165,20 +178,23 @@ class BoardComponent extends React.Component<Props, State> {
<div className='octo-board-header-cell narrow'>
<FormattedMessage
id='BoardComponent.hidden-columns'
defaultMessage='Hidden Columns'
defaultMessage='Hidden columns'
/>
</div>}
</div>
}
<div className='octo-board-header-cell narrow'>
<Button
onClick={this.addGroupClicked}
>
<FormattedMessage
id='BoardComponent.add-a-group'
defaultMessage='+ Add a group'
/>
</Button>
</div>
{!this.props.readonly &&
<div className='octo-board-header-cell narrow'>
<Button
onClick={this.addGroupClicked}
>
<FormattedMessage
id='BoardComponent.add-a-group'
defaultMessage='+ Add a group'
/>
</Button>
</div>
}
</div>
{/* Main content */}
@ -196,16 +212,18 @@ class BoardComponent extends React.Component<Props, State> {
onDrop={() => this.onDropToColumn(group.option)}
>
{group.cards.map((card) => this.renderCard(card, visiblePropertyTemplates))}
<Button
onClick={() => {
this.addCard(group.option.id)
}}
>
<FormattedMessage
id='BoardComponent.neww'
defaultMessage='+ New'
/>
</Button>
{!this.props.readonly &&
<Button
onClick={() => {
this.addCard(group.option.id)
}}
>
<FormattedMessage
id='BoardComponent.neww'
defaultMessage='+ New'
/>
</Button>
}
</BoardColumn>
))}
@ -231,6 +249,7 @@ class BoardComponent extends React.Component<Props, State> {
card={card}
visiblePropertyTemplates={visiblePropertyTemplates}
key={card.id}
readonly={this.props.readonly}
isSelected={this.state.selectedCardIds.includes(card.id)}
onClick={(e) => {
this.cardClicked(e, card)
@ -268,7 +287,7 @@ class BoardComponent extends React.Component<Props, State> {
ref={ref}
className='octo-board-header-cell'
draggable={true}
draggable={!this.props.readonly}
onDragStart={() => {
this.draggedHeaderOption = group.option
}}
@ -311,21 +330,25 @@ class BoardComponent extends React.Component<Props, State> {
</div>
<Button>{`${group.cards.length}`}</Button>
<div className='octo-spacer'/>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='hide'
icon={<HideIcon/>}
name={intl.formatMessage({id: 'BoardComponent.hide', defaultMessage: 'Hide'})}
onClick={() => mutator.hideViewColumn(activeView, '')}
{!this.props.readonly &&
<>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='hide'
icon={<HideIcon/>}
name={intl.formatMessage({id: 'BoardComponent.hide', defaultMessage: 'Hide'})}
onClick={() => mutator.hideViewColumn(activeView, '')}
/>
</Menu>
</MenuWrapper>
<IconButton
icon={<AddIcon/>}
onClick={() => this.addCard(undefined)}
/>
</Menu>
</MenuWrapper>
<IconButton
icon={<AddIcon/>}
onClick={() => this.addCard(undefined)}
/>
</>
}
</div>
)
}
@ -337,7 +360,7 @@ class BoardComponent extends React.Component<Props, State> {
ref={ref}
className='octo-board-header-cell'
draggable={true}
draggable={!this.props.readonly}
onDragStart={() => {
this.draggedHeaderOption = group.option
}}
@ -371,39 +394,44 @@ class BoardComponent extends React.Component<Props, State> {
onChanged={(text) => {
this.propertyNameChanged(group.option, text)
}}
readonly={this.props.readonly}
/>
<Button>{`${group.cards.length}`}</Button>
<div className='octo-spacer'/>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='hide'
icon={<HideIcon/>}
name={intl.formatMessage({id: 'BoardComponent.hide', defaultMessage: 'Hide'})}
onClick={() => mutator.hideViewColumn(activeView, group.option.id)}
{!this.props.readonly &&
<>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='hide'
icon={<HideIcon/>}
name={intl.formatMessage({id: 'BoardComponent.hide', defaultMessage: 'Hide'})}
onClick={() => mutator.hideViewColumn(activeView, group.option.id)}
/>
<Menu.Text
id='delete'
icon={<DeleteIcon/>}
name={intl.formatMessage({id: 'BoardComponent.delete', defaultMessage: 'Delete'})}
onClick={() => mutator.deletePropertyOption(boardTree, boardTree.groupByProperty!, group.option)}
/>
<Menu.Separator/>
{Constants.menuColors.map((color) => (
<Menu.Color
key={color.id}
id={color.id}
name={color.name}
onClick={() => mutator.changePropertyOptionColor(boardTree.board, boardTree.groupByProperty!, group.option, color.id)}
/>
))}
</Menu>
</MenuWrapper>
<IconButton
icon={<AddIcon/>}
onClick={() => this.addCard(group.option.id)}
/>
<Menu.Text
id='delete'
icon={<DeleteIcon/>}
name={intl.formatMessage({id: 'BoardComponent.delete', defaultMessage: 'Delete'})}
onClick={() => mutator.deletePropertyOption(boardTree, boardTree.groupByProperty!, group.option)}
/>
<Menu.Separator/>
{Constants.menuColors.map((color) => (
<Menu.Color
key={color.id}
id={color.id}
name={color.name}
onClick={() => mutator.changePropertyOptionColor(boardTree.board, boardTree.groupByProperty!, group.option, color.id)}
/>
))}
</Menu>
</MenuWrapper>
<IconButton
icon={<AddIcon/>}
onClick={() => this.addCard(group.option.id)}
/>
</>
}
</div>
)
}
@ -448,7 +476,9 @@ class BoardComponent extends React.Component<Props, State> {
this.onDropToColumn(group.option)
}}
>
<MenuWrapper>
<MenuWrapper
disabled={this.props.readonly}
>
<div
key={group.option.id || 'empty'}
className={`octo-label ${group.option.color}`}
@ -482,10 +512,10 @@ class BoardComponent extends React.Component<Props, State> {
this.props.intl.formatMessage({id: 'Mutator.new-card-from-template', defaultMessage: 'new card from template'}),
false,
async (newCardId) => {
this.setState({shownCardId: newCardId})
this.showCard(newCardId)
},
async () => {
this.setState({shownCardId: undefined})
this.showCard(undefined)
},
)
}
@ -514,10 +544,10 @@ class BoardComponent extends React.Component<Props, State> {
card,
'add card',
async () => {
this.setState({shownCardId: card.id})
this.showCard(card.id)
},
async () => {
this.setState({shownCardId: undefined})
this.showCard(undefined)
},
)
}
@ -533,15 +563,15 @@ class BoardComponent extends React.Component<Props, State> {
cardTemplate,
'add card template',
async () => {
this.setState({shownCardId: cardTemplate.id})
this.showCard(cardTemplate.id)
}, async () => {
this.setState({shownCardId: undefined})
this.showCard(undefined)
},
)
}
private editCardTemplate = (cardTemplateId: string) => {
this.setState({shownCardId: cardTemplateId})
this.showCard(cardTemplateId)
}
private async propertyNameChanged(option: IPropertyOption, text: string): Promise<void> {
@ -577,12 +607,17 @@ class BoardComponent extends React.Component<Props, State> {
this.setState({selectedCardIds})
}
} else {
this.setState({selectedCardIds: [], shownCardId: card.id})
this.showCard(card.id)
}
e.stopPropagation()
}
private showCard = (cardId?: string) => {
Utils.replaceUrlQueryParam('c', cardId)
this.setState({selectedCardIds: [], shownCardId: cardId})
}
private addGroupClicked = async () => {
Utils.log('onAddGroupClicked')

View File

@ -30,6 +30,7 @@ type Props = {
boardTree: BoardTree
cardTree: CardTree
intl: IntlShape
readonly: boolean
}
type State = {
@ -74,6 +75,7 @@ class CardDetail extends React.Component<Props, State> {
block={block}
card={card}
contents={cardTree.contents}
readonly={this.props.readonly}
/>
))}
</div>)
@ -81,20 +83,17 @@ class CardDetail extends React.Component<Props, State> {
contentElements = (<div className='octo-content'>
<div className='octo-block'>
<div className='octo-block-margin'/>
<MarkdownEditor
text=''
placeholderText='Add a description...'
onBlur={(text) => {
if (text) {
const block = new MutableTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.title = text
block.order = (this.props.cardTree.contents.length + 1) * 1000
mutator.insertBlock(block, 'add card text')
}
}}
/>
{!this.props.readonly &&
<MarkdownEditor
text=''
placeholderText='Add a description...'
onBlur={(text) => {
if (text) {
this.addTextBlock(text)
}
}}
/>
}
</div>
</div>)
}
@ -107,8 +106,9 @@ class CardDetail extends React.Component<Props, State> {
<BlockIconSelector
block={card}
size='l'
readonly={this.props.readonly}
/>
{!icon &&
{!this.props.readonly && !icon &&
<div className='add-buttons'>
<Button
onClick={() => {
@ -119,7 +119,7 @@ class CardDetail extends React.Component<Props, State> {
>
<FormattedMessage
id='CardDetail.add-icon'
defaultMessage='Add Icon'
defaultMessage='Add icon'
/>
</Button>
</div>}
@ -137,6 +137,7 @@ class CardDetail extends React.Component<Props, State> {
}
}}
onCancel={() => this.setState({title: this.props.cardTree.card.title})}
readonly={this.props.readonly}
/>
{/* Property list */}
@ -148,19 +149,22 @@ class CardDetail extends React.Component<Props, State> {
key={propertyTemplate.id}
className='octo-propertyrow'
>
<MenuWrapper>
<div className='octo-propertyname'><Button>{propertyTemplate.name}</Button></div>
<PropertyMenu
propertyId={propertyTemplate.id}
propertyName={propertyTemplate.name}
propertyType={propertyTemplate.type}
onNameChanged={(newName: string) => mutator.renameProperty(board, propertyTemplate.id, newName)}
onTypeChanged={(newType: PropertyType) => mutator.changePropertyType(boardTree, propertyTemplate, newType)}
onDelete={(id: string) => mutator.deleteProperty(boardTree, id)}
/>
</MenuWrapper>
{this.props.readonly && <div className='octo-propertyname'>{propertyTemplate.name}</div>}
{!this.props.readonly &&
<MenuWrapper>
<div className='octo-propertyname'><Button>{propertyTemplate.name}</Button></div>
<PropertyMenu
propertyId={propertyTemplate.id}
propertyName={propertyTemplate.name}
propertyType={propertyTemplate.type}
onNameChanged={(newName: string) => mutator.renameProperty(board, propertyTemplate.id, newName)}
onTypeChanged={(newType: PropertyType) => mutator.changePropertyType(boardTree, propertyTemplate, newType)}
onDelete={(id: string) => mutator.deleteProperty(boardTree, id)}
/>
</MenuWrapper>
}
<PropertyValueElement
readOnly={false}
readOnly={this.props.readonly}
card={card}
boardTree={boardTree}
propertyTemplate={propertyTemplate}
@ -170,29 +174,35 @@ class CardDetail extends React.Component<Props, State> {
)
})}
<div className='octo-propertyname add-property'>
<Button
onClick={async () => {
// TODO: Show UI
await mutator.insertPropertyTemplate(boardTree)
}}
>
<FormattedMessage
id='CardDetail.add-property'
defaultMessage='+ Add a property'
/>
</Button>
</div>
{!this.props.readonly &&
<div className='octo-propertyname add-property'>
<Button
onClick={async () => {
// TODO: Show UI
await mutator.insertPropertyTemplate(boardTree)
}}
>
<FormattedMessage
id='CardDetail.add-property'
defaultMessage='+ Add a property'
/>
</Button>
</div>
}
</div>
{/* Comments */}
<hr/>
<CommentsList
comments={comments}
cardId={card.id}
/>
<hr/>
{!this.props.readonly &&
<>
<hr/>
<CommentsList
comments={comments}
cardId={card.id}
/>
<hr/>
</>
}
</div>
{/* Content blocks */}
@ -201,41 +211,66 @@ class CardDetail extends React.Component<Props, State> {
{contentElements}
</div>
<div className='CardDetail content add-content'>
<MenuWrapper>
<Button>
<FormattedMessage
id='CardDetail.add-content'
defaultMessage='Add content'
/>
</Button>
<Menu position='top'>
<Menu.Text
id='text'
name={intl.formatMessage({id: 'CardDetail.text', defaultMessage: 'Text'})}
onClick={() => {
const block = new MutableTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.order = (this.props.cardTree.contents.length + 1) * 1000
mutator.insertBlock(block, 'add text')
}}
/>
<Menu.Text
id='image'
name={intl.formatMessage({id: 'CardDetail.image', defaultMessage: 'Image'})}
onClick={() => Utils.selectLocalFile(
(file) => mutator.createImageBlock(card, file, (this.props.cardTree.contents.length + 1) * 1000),
{!this.props.readonly &&
<div className='CardDetail content add-content'>
<MenuWrapper>
<Button>
<FormattedMessage
id='CardDetail.add-content'
defaultMessage='Add content'
/>
</Button>
<Menu position='top'>
<Menu.Text
id='text'
name={intl.formatMessage({id: 'CardDetail.text', defaultMessage: 'Text'})}
onClick={() => {
this.addTextBlock('')
}}
/>
<Menu.Text
id='image'
name={intl.formatMessage({id: 'CardDetail.image', defaultMessage: 'Image'})}
onClick={() => Utils.selectLocalFile((file) => {
mutator.performAsUndoGroup(async () => {
const description = intl.formatMessage({id: 'ContentBlock.addImage', defaultMessage: 'add image'})
const newBlock = await mutator.createImageBlock(card, file, description)
if (newBlock) {
const contentOrder = card.contentOrder.slice()
contentOrder.push(newBlock.id)
await mutator.changeCardContentOrder(card, contentOrder, description)
}
})
},
'.jpg,.jpeg,.png',
)}
/>
)}
/>
</Menu>
</MenuWrapper>
</div>
</Menu>
</MenuWrapper>
</div>
}
</>
)
}
private addTextBlock(text: string): void {
const {intl, cardTree} = this.props
const {card} = cardTree
const block = new MutableTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.title = text
const contentOrder = card.contentOrder.slice()
contentOrder.push(block.id)
mutator.performAsUndoGroup(async () => {
const description = intl.formatMessage({id: 'CardDetail.addCardText', defaultMessage: 'add card text'})
await mutator.insertBlock(block, description)
await mutator.changeCardContentOrder(card, contentOrder, description)
})
}
}
export default injectIntl(CardDetail)

View File

@ -20,14 +20,16 @@ type Props = {
onClose: () => void
showCard: (cardId?: string) => void
intl: IntlShape
readonly: boolean
}
type State = {
cardTree?: CardTree
cardTree?: CardTree,
syncComplete: boolean
}
class CardDialog extends React.Component<Props, State> {
state: State = {}
state: State = {syncComplete: false}
private cardListener?: OctoListener
@ -40,38 +42,40 @@ class CardDialog extends React.Component<Props, State> {
}
private async createCardTreeAndSync() {
const cardTree = new MutableCardTree(this.props.cardId)
await cardTree.sync()
const cardTree = await MutableCardTree.sync(this.props.cardId)
this.createListener()
this.setState({cardTree})
Utils.log(`cardDialog.createCardTreeAndSync: ${cardTree.card.id}`)
this.setState({cardTree, syncComplete: true})
Utils.log(`cardDialog.createCardTreeAndSync: ${cardTree?.card.id}`)
}
private createListener() {
this.deleteListener()
this.cardListener = new OctoListener()
this.cardListener.open(
[this.props.cardId],
async (blocks) => {
Utils.log(`cardListener.onChanged: ${blocks.length}`)
const newCardTree = this.state.cardTree!.mutableCopy()
if (newCardTree.incrementalUpdate(blocks)) {
this.setState({cardTree: newCardTree})
}
const newCardTree = this.state.cardTree ? MutableCardTree.incrementalUpdate(this.state.cardTree, blocks) : await MutableCardTree.sync(this.props.cardId)
this.setState({cardTree: newCardTree, syncComplete: true})
},
async () => {
Utils.log('cardListener.onReconnect')
const newCardTree = this.state.cardTree!.mutableCopy()
await newCardTree.sync()
this.setState({cardTree: newCardTree})
const newCardTree = await MutableCardTree.sync(this.props.cardId)
this.setState({cardTree: newCardTree, syncComplete: true})
},
)
}
componentWillUnmount(): void {
private deleteListener() {
this.cardListener?.close()
this.cardListener = undefined
}
componentWillUnmount(): void {
this.deleteListener()
}
render(): JSX.Element {
const {cardTree} = this.state
@ -103,7 +107,7 @@ class CardDialog extends React.Component<Props, State> {
return (
<Dialog
onClose={this.props.onClose}
toolsMenu={menu}
toolsMenu={!this.props.readonly && menu}
>
{(cardTree?.card.isTemplate) &&
<div className='banner'>
@ -117,8 +121,17 @@ class CardDialog extends React.Component<Props, State> {
<CardDetail
boardTree={this.props.boardTree}
cardTree={this.state.cardTree}
readonly={this.props.readonly}
/>
}
{(!this.state.cardTree && this.state.syncComplete) &&
<div className='banner error'>
<FormattedMessage
id='CardDialog.nocard'
defaultMessage="This card doesn't exist or is inaccessible"
/>
</div>
}
</Dialog>
)
}

View File

@ -2,13 +2,13 @@
// See LICENSE.txt for license information.
import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {IBlock} from '../blocks/block'
import {Card} from '../blocks/card'
import {IContentBlock} from '../blocks/contentBlock'
import {MutableDividerBlock} from '../blocks/dividerBlock'
import {IOrderedBlock} from '../blocks/orderedBlock'
import {MutableTextBlock} from '../blocks/textBlock'
import mutator from '../mutator'
import {OctoUtils} from '../octoUtils'
import {Utils} from '../utils'
import IconButton from '../widgets/buttons/iconButton'
import AddIcon from '../widgets/icons/add'
@ -26,14 +26,16 @@ import './contentBlock.scss'
import {MarkdownEditor} from './markdownEditor'
type Props = {
block: IOrderedBlock
card: IBlock
contents: readonly IOrderedBlock[]
block: IContentBlock
card: Card
contents: readonly IContentBlock[]
readonly: boolean
intl: IntlShape
}
class ContentBlock extends React.PureComponent<Props> {
public render(): JSX.Element | null {
const {card, contents, block} = this.props
const {intl, card, contents, block} = this.props
if (block.type !== 'text' && block.type !== 'image' && block.type !== 'divider') {
Utils.assertFailure(`Block type is unknown: ${block.type}`)
@ -44,98 +46,118 @@ class ContentBlock extends React.PureComponent<Props> {
return (
<div className='ContentBlock octo-block'>
<div className='octo-block-margin'>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
{index > 0 &&
<Menu.Text
id='moveUp'
name='Move up'
icon={<SortUpIcon/>}
onClick={() => {
const previousBlock = contents[index - 1]
const newOrder = OctoUtils.getOrderBefore(previousBlock, contents)
Utils.log(`moveUp ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move up')
}}
/>}
{index < (contents.length - 1) &&
<Menu.Text
id='moveDown'
name='Move down'
icon={<SortDownIcon/>}
onClick={() => {
const nextBlock = contents[index + 1]
const newOrder = OctoUtils.getOrderAfter(nextBlock, contents)
Utils.log(`moveDown ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move down')
}}
/>}
<Menu.SubMenu
id='insertAbove'
name='Insert above'
icon={<AddIcon/>}
>
<Menu.Text
id='text'
name='Text'
icon={<TextIcon/>}
onClick={() => {
const newBlock = new MutableTextBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
{!this.props.readonly &&
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
{index > 0 &&
<Menu.Text
id='moveUp'
name={intl.formatMessage({id: 'ContentBlock.moveUp', defaultMessage: 'Move up'})}
icon={<SortUpIcon/>}
onClick={() => {
const contentOrder = contents.map((o) => o.id)
Utils.arrayMove(contentOrder, index, index - 1)
mutator.changeCardContentOrder(card, contentOrder)
}}
/>}
{index < (contents.length - 1) &&
<Menu.Text
id='moveDown'
name={intl.formatMessage({id: 'ContentBlock.moveDown', defaultMessage: 'Move down'})}
icon={<SortDownIcon/>}
onClick={() => {
const contentOrder = contents.map((o) => o.id)
Utils.arrayMove(contentOrder, index, index + 1)
mutator.changeCardContentOrder(card, contentOrder)
}}
/>}
<Menu.SubMenu
id='insertAbove'
name={intl.formatMessage({id: 'ContentBlock.insertAbove', defaultMessage: 'Insert above'})}
icon={<AddIcon/>}
>
<Menu.Text
id='text'
name={intl.formatMessage({id: 'ContentBlock.Text', defaultMessage: 'Text'})}
icon={<TextIcon/>}
onClick={() => {
const newBlock = new MutableTextBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
// TODO: Handle need to reorder all blocks
newBlock.order = OctoUtils.getOrderBefore(block, contents)
Utils.log(`insert block ${block.id}, order: ${block.order}`)
mutator.insertBlock(newBlock, 'insert card text')
}}
/>
<Menu.Text
id='image'
name='Image'
icon={<ImageIcon/>}
onClick={() => {
Utils.selectLocalFile(
(file) => {
mutator.createImageBlock(card, file, OctoUtils.getOrderBefore(block, contents))
const contentOrder = contents.map((o) => o.id)
contentOrder.splice(index, 0, newBlock.id)
mutator.performAsUndoGroup(async () => {
const description = intl.formatMessage({id: 'ContentBlock.addText', defaultMessage: 'add text'})
await mutator.insertBlock(newBlock, description)
await mutator.changeCardContentOrder(card, contentOrder, description)
})
}}
/>
<Menu.Text
id='image'
name='Image'
icon={<ImageIcon/>}
onClick={() => {
Utils.selectLocalFile((file) => {
mutator.performAsUndoGroup(async () => {
const description = intl.formatMessage({id: 'ContentBlock.addImage', defaultMessage: 'add image'})
const newBlock = await mutator.createImageBlock(card, file, description)
if (newBlock) {
const contentOrder = contents.map((o) => o.id)
contentOrder.splice(index, 0, newBlock.id)
await mutator.changeCardContentOrder(card, contentOrder, description)
}
})
},
'.jpg,.jpeg,.png')
}}
/>
<Menu.Text
id='divider'
name='Divider'
icon={<DividerIcon/>}
onClick={() => {
const newBlock = new MutableDividerBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
}}
/>
<Menu.Text
id='divider'
name={intl.formatMessage({id: 'ContentBlock.divider', defaultMessage: 'Divider'})}
icon={<DividerIcon/>}
onClick={() => {
const newBlock = new MutableDividerBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
// TODO: Handle need to reorder all blocks
newBlock.order = OctoUtils.getOrderBefore(block, contents)
Utils.log(`insert block ${block.id}, order: ${block.order}`)
mutator.insertBlock(newBlock, 'insert card text')
const contentOrder = contents.map((o) => o.id)
contentOrder.splice(index, 0, newBlock.id)
mutator.performAsUndoGroup(async () => {
const description = intl.formatMessage({id: 'ContentBlock.addDivider', defaultMessage: 'add divider'})
await mutator.insertBlock(newBlock, description)
await mutator.changeCardContentOrder(card, contentOrder, description)
})
}}
/>
</Menu.SubMenu>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'ContentBlock.Delete', defaultMessage: 'Delete'})}
onClick={() => {
const description = intl.formatMessage({id: 'ContentBlock.DeleteAction', defaultMessage: 'delete'})
const contentOrder = contents.map((o) => o.id).filter((o) => o !== block.id)
mutator.performAsUndoGroup(async () => {
await mutator.deleteBlock(block, description)
await mutator.changeCardContentOrder(card, contentOrder, description)
})
}}
/>
</Menu.SubMenu>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name='Delete'
onClick={() => mutator.deleteBlock(block)}
/>
</Menu>
</MenuWrapper>
</Menu>
</MenuWrapper>
}
</div>
{block.type === 'text' &&
<MarkdownEditor
text={block.title}
placeholderText='Edit text...'
placeholderText={intl.formatMessage({id: 'ContentBlock.editText', defaultMessage: 'Edit text...'})}
onBlur={(text) => {
Utils.log(`change text ${block.id}, ${text}`)
mutator.changeTitle(block, text, 'edit card text')
mutator.changeTitle(block, text, intl.formatMessage({id: 'ContentBlock.editCardText', defaultMessage: 'edit card text'}))
}}
readonly={this.props.readonly}
/>}
{block.type === 'divider' && <div className='divider'/>}
{block.type === 'image' &&
@ -148,4 +170,4 @@ class ContentBlock extends React.PureComponent<Props> {
}
}
export default ContentBlock
export default injectIntl(ContentBlock)

View File

@ -48,6 +48,9 @@
text-align: center;
padding: 10px;
}
> .banner.error {
background-color: rgba(230, 192, 192, 0.9);
}
> .toolbar {
display: flex;
flex-direction: row;

View File

@ -47,20 +47,23 @@ export default class Dialog extends React.PureComponent<Props> {
}}
>
<div className='dialog' >
{toolsMenu &&
<div className='toolbar'>
<IconButton
onClick={this.closeClicked}
icon={<CloseIcon/>}
title={'Close dialog'}
className='hideOnWidescreen'
/>
<div className='octo-spacer'/>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
{toolsMenu}
</MenuWrapper>
</div>}
{toolsMenu &&
<>
<IconButton
onClick={this.closeClicked}
icon={<CloseIcon/>}
title={'Close dialog'}
className='hideOnWidescreen'
/>
<div className='octo-spacer'/>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
{toolsMenu}
</MenuWrapper>
</>
}
</div>
{this.props.children}
</div>
</div>

View File

@ -13,6 +13,7 @@ type Props = {
isMarkdown: boolean
isMultiline: boolean
allowEmpty: boolean
readonly?: boolean
onFocus?: () => void
onBlur?: () => void
@ -72,7 +73,7 @@ class Editable extends React.PureComponent<Props> {
}
render(): JSX.Element {
const {text, className, style, placeholderText, isMarkdown, isMultiline, onFocus, onBlur, onKeyDown, onChanged} = this.props
const {text, style, placeholderText, isMarkdown, isMultiline, onFocus, onBlur, onKeyDown, onChanged} = this.props
const initialStyle = {...this.props.style}
@ -83,11 +84,16 @@ class Editable extends React.PureComponent<Props> {
html = ''
}
let className = 'octo-editable'
if (this.props.className) {
className += ' ' + this.props.className
}
const element = (
<div
ref={this.elementRef}
className={'octo-editable ' + className}
contentEditable={true}
className={className}
contentEditable={!this.props.readonly}
suppressContentEditableWarning={true}
style={initialStyle}
placeholder={placeholderText}
@ -95,6 +101,10 @@ class Editable extends React.PureComponent<Props> {
dangerouslySetInnerHTML={{__html: html}}
onFocus={() => {
if (this.props.readonly) {
return
}
if (this.elementRef.current) {
this.elementRef.current.innerText = this.text
this.elementRef.current.style.color = style?.color || ''
@ -107,6 +117,10 @@ class Editable extends React.PureComponent<Props> {
}}
onBlur={async () => {
if (this.props.readonly) {
return
}
if (this.elementRef.current) {
const newText = this.elementRef.current.innerText
const oldText = this.props.text || ''
@ -129,6 +143,10 @@ class Editable extends React.PureComponent<Props> {
}}
onKeyDown={(e) => {
if (this.props.readonly) {
return
}
if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC
e.stopPropagation()
this.elementRef.current?.blur()

View File

@ -162,7 +162,7 @@ class FilterComponent extends React.Component<Props> {
<Button onClick={() => this.addFilterClicked()}>
<FormattedMessage
id='FilterComponent.add-filter'
defaultMessage='+ Add Filter'
defaultMessage='+ Add filter'
/>
</Button>
</div>

View File

@ -1,6 +1,7 @@
.HorizontalGrip {
width: 5px;
cursor: ew-resize;
flex-shrink: 0;
&:hover {
background-color: rgba(90, 192, 255, 0.7);

View File

@ -12,6 +12,7 @@ type Props = {
placeholderText?: string
uniqueId?: string
className?: string
readonly?: boolean
onChange?: (text: string) => void
onFocus?: () => void
@ -28,7 +29,7 @@ class MarkdownEditor extends React.Component<Props, State> {
}
get text(): string {
return this.elementRef.current!.state.value
return this.elementRef.current!.state.value || ''
}
set text(value: string) {
this.elementRef.current!.setState({value})
@ -91,7 +92,7 @@ class MarkdownEditor extends React.Component<Props, State> {
style={{display: this.state.isEditing ? 'none' : undefined}}
dangerouslySetInnerHTML={{__html: html}}
onClick={() => {
if (!this.state.isEditing) {
if (!this.props.readonly && !this.state.isEditing) {
this.showEditor()
}
}}

View File

@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import mutator from '../mutator'
import {BoardTree} from '../viewModel/boardTree'
import ButtonWithMenu from '../widgets/buttons/buttonWithMenu'
import IconButton from '../widgets/buttons/iconButton'
import CardIcon from '../widgets/icons/card'
import DeleteIcon from '../widgets/icons/delete'
import OptionsIcon from '../widgets/icons/options'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
type Props = {
boardTree: BoardTree
addCard: () => void
addCardFromTemplate: (cardTemplateId: string) => void
addCardTemplate: () => void
editCardTemplate: (cardTemplateId: string) => void
intl: IntlShape
}
class NewCardButton extends React.PureComponent<Props> {
render(): JSX.Element {
const {intl, boardTree} = this.props
return (
<ButtonWithMenu
onClick={() => {
this.props.addCard()
}}
text={(
<FormattedMessage
id='ViewHeader.new'
defaultMessage='New'
/>
)}
>
<Menu position='left'>
{boardTree.cardTemplates.length > 0 && <>
<Menu.Label>
<b>
<FormattedMessage
id='ViewHeader.select-a-template'
defaultMessage='Select a template'
/>
</b>
</Menu.Label>
<Menu.Separator/>
</>}
{boardTree.cardTemplates.map((cardTemplate) => {
const displayName = cardTemplate.title || intl.formatMessage({id: 'ViewHeader.untitled', defaultMessage: 'Untitled'})
return (
<Menu.Text
key={cardTemplate.id}
id={cardTemplate.id}
name={displayName}
icon={<div className='Icon'>{cardTemplate.icon}</div>}
onClick={() => {
this.props.addCardFromTemplate(cardTemplate.id)
}}
rightIcon={
<MenuWrapper stopPropagationOnToggle={true}>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='left'>
<Menu.Text
id='edit'
name={intl.formatMessage({id: 'ViewHeader.edit-template', defaultMessage: 'Edit'})}
onClick={() => {
this.props.editCardTemplate(cardTemplate.id)
}}
/>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'ViewHeader.delete-template', defaultMessage: 'Delete'})}
onClick={async () => {
await mutator.deleteBlock(cardTemplate, 'delete card template')
}}
/>
</Menu>
</MenuWrapper>
}
/>
)
})}
<Menu.Text
id='empty-template'
name={intl.formatMessage({id: 'ViewHeader.empty-card', defaultMessage: 'Empty card'})}
icon={<CardIcon/>}
onClick={() => {
this.props.addCard()
}}
/>
<Menu.Text
id='add-template'
name={intl.formatMessage({id: 'ViewHeader.add-template', defaultMessage: '+ New template'})}
onClick={() => this.props.addCardTemplate()}
/>
</Menu>
</ButtonWithMenu>
)
}
}
export default injectIntl(NewCardButton)

View File

@ -50,7 +50,7 @@ export default class PropertyValueElement extends React.Component<Props, State>
}
if (propertyTemplate.type === 'select') {
let className = 'octo-propertyvalue'
let className = 'octo-propertyvalue octo-label'
if (!displayValue) {
className += ' empty'
}

View File

@ -7,7 +7,7 @@
min-height: 100%;
color: rgb(var(--sidebar-fg));
background-color: rgb(var(--sidebar-bg));
padding: 10px 0;
padding-top: 10px;
&.hidden {
position: absolute;
@ -26,12 +26,12 @@
}
>* {
flex-shrink: 0;
flex: 0 0 auto;
}
.octo-sidebar-list {
flex: 1 1 auto;
overflow-y: auto;
max-height: calc(100% - 78px);
max-width: 250px;
}
@ -135,4 +135,8 @@
stroke: rgba(var(--sidebar-fg), 0.5);
stroke-width: 6px;
}
.Button {
min-height: 30px;
}
}

View File

@ -7,10 +7,11 @@ import {Archiver} from '../archiver'
import {Board, MutableBoard} from '../blocks/board'
import {BoardView, MutableBoardView} from '../blocks/boardView'
import mutator from '../mutator'
import {darkTheme, lightTheme, mattermostTheme, setTheme} from '../theme'
import {defaultTheme, darkTheme, lightTheme, setTheme} from '../theme'
import {WorkspaceTree} from '../viewModel/workspaceTree'
import Button from '../widgets/buttons/button'
import IconButton from '../widgets/buttons/iconButton'
import BoardIcon from '../widgets/icons/board'
import DeleteIcon from '../widgets/icons/delete'
import DisclosureTriangle from '../widgets/icons/disclosureTriangle'
import DotIcon from '../widgets/icons/dot'
@ -24,7 +25,7 @@ import MenuWrapper from '../widgets/menuWrapper'
import './sidebar.scss'
type Props = {
showBoard: (id: string) => void
showBoard: (id?: string) => void
showView: (id: string, boardId?: string) => void
workspaceTree: WorkspaceTree,
activeBoardId?: string
@ -80,7 +81,7 @@ class Sidebar extends React.Component<Props, State> {
return (
<div className='Sidebar octo-sidebar'>
<div className='octo-sidebar-header'>
{'OCTO'}
{'Mattergoals'}
<div className='octo-spacer'/>
<IconButton
onClick={this.hideClicked}
@ -117,15 +118,18 @@ class Sidebar extends React.Component<Props, State> {
<Menu position='left'>
<Menu.Text
id='deleteBoard'
name={intl.formatMessage({id: 'Sidebar.delete-board', defaultMessage: 'Delete Board'})}
name={intl.formatMessage({id: 'Sidebar.delete-board', defaultMessage: 'Delete board'})}
icon={<DeleteIcon/>}
onClick={async () => {
const nextBoardId = boards.length > 1 ? boards.find((o) => o.id !== board.id)?.id : undefined
mutator.deleteBlock(
board,
'delete block',
intl.formatMessage({id: 'Sidebar.delete-board', defaultMessage: 'Delete board'}),
async () => {
nextBoardId && this.props.showBoard(nextBoardId!)
// This delay is needed because OctoListener has a default 100 ms notification delay before updates
setTimeout(() => {
this.props.showBoard(nextBoardId)
}, 120)
},
async () => {
this.props.showBoard(board.id)
@ -136,7 +140,7 @@ class Sidebar extends React.Component<Props, State> {
<Menu.Text
id='duplicateBoard'
name={intl.formatMessage({id: 'Sidebar.duplicate-board', defaultMessage: 'Duplicate Board'})}
name={intl.formatMessage({id: 'Sidebar.duplicate-board', defaultMessage: 'Duplicate board'})}
icon={<DuplicateIcon/>}
onClick={() => {
this.duplicateBoard(board.id)
@ -181,17 +185,19 @@ class Sidebar extends React.Component<Props, State> {
)
})
}
</div>
<br/>
<div className='octo-spacer'/>
<MenuWrapper>
<Button>
<FormattedMessage
id='Sidebar.add-board'
defaultMessage='+ Add Board'
/>
</Button>
<Menu position='top'>
<MenuWrapper>
<Button>
<FormattedMessage
id='Sidebar.add-board'
defaultMessage='+ Add Board'
/>
</Button>
<Menu position='top'>
{workspaceTree.boardTemplates.length > 0 && <>
<Menu.Label>
<b>
<FormattedMessage
@ -202,62 +208,60 @@ class Sidebar extends React.Component<Props, State> {
</Menu.Label>
<Menu.Separator/>
</>}
{workspaceTree.boardTemplates.map((boardTemplate) => {
let displayName = boardTemplate.title || intl.formatMessage({id: 'Sidebar.untitled', defaultMessage: 'Untitled'})
if (boardTemplate.icon) {
displayName = `${boardTemplate.icon} ${displayName}`
}
return (
<Menu.Text
key={boardTemplate.id}
id={boardTemplate.id}
name={displayName}
onClick={() => {
this.addBoardFromTemplate(boardTemplate.id)
}}
rightIcon={
<MenuWrapper stopPropagationOnToggle={true}>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='left'>
<Menu.Text
id='edit'
name={intl.formatMessage({id: 'Sidebar.edit-template', defaultMessage: 'Edit'})}
onClick={() => {
this.props.showBoard(boardTemplate.id)
}}
/>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'Sidebar.delete-template', defaultMessage: 'Delete'})}
onClick={async () => {
await mutator.deleteBlock(boardTemplate, 'delete board template')
}}
/>
</Menu>
</MenuWrapper>
}
/>
)
})}
{workspaceTree.boardTemplates.map((boardTemplate) => {
const displayName = boardTemplate.title || intl.formatMessage({id: 'Sidebar.untitled', defaultMessage: 'Untitled'})
<Menu.Text
id='empty-template'
name={intl.formatMessage({id: 'Sidebar.empty-board', defaultMessage: 'Empty board'})}
onClick={this.addBoardClicked}
/>
return (
<Menu.Text
key={boardTemplate.id}
id={boardTemplate.id}
name={displayName}
icon={<div className='Icon'>{boardTemplate.icon}</div>}
onClick={() => {
this.addBoardFromTemplate(boardTemplate.id)
}}
rightIcon={
<MenuWrapper stopPropagationOnToggle={true}>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='left'>
<Menu.Text
id='edit'
name={intl.formatMessage({id: 'Sidebar.edit-template', defaultMessage: 'Edit'})}
onClick={() => {
this.props.showBoard(boardTemplate.id)
}}
/>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'Sidebar.delete-template', defaultMessage: 'Delete'})}
onClick={async () => {
await mutator.deleteBlock(boardTemplate, 'delete board template')
}}
/>
</Menu>
</MenuWrapper>
}
/>
)
})}
<Menu.Text
id='add-template'
name={intl.formatMessage({id: 'Sidebar.add-template', defaultMessage: '+ New template'})}
onClick={this.addBoardTemplateClicked}
/>
</Menu>
</MenuWrapper>
</div>
<Menu.Text
id='empty-template'
name={intl.formatMessage({id: 'Sidebar.empty-board', defaultMessage: 'Empty board'})}
icon={<BoardIcon/>}
onClick={this.addBoardClicked}
/>
<div className='octo-spacer'/>
<Menu.Text
id='add-template'
name={intl.formatMessage({id: 'Sidebar.add-template', defaultMessage: '+ New template'})}
onClick={this.addBoardTemplateClicked}
/>
</Menu>
</MenuWrapper>
<MenuWrapper>
<Button>
@ -269,17 +273,17 @@ class Sidebar extends React.Component<Props, State> {
<Menu position='top'>
<Menu.Text
id='import'
name={intl.formatMessage({id: 'Sidebar.import-archive', defaultMessage: 'Import Archive'})}
name={intl.formatMessage({id: 'Sidebar.import-archive', defaultMessage: 'Import archive'})}
onClick={async () => Archiver.importFullArchive()}
/>
<Menu.Text
id='export'
name={intl.formatMessage({id: 'Sidebar.export-archive', defaultMessage: 'Export Archive'})}
name={intl.formatMessage({id: 'Sidebar.export-archive', defaultMessage: 'Export archive'})}
onClick={async () => Archiver.exportFullArchive()}
/>
<Menu.SubMenu
id='lang'
name={intl.formatMessage({id: 'Sidebar.set-language', defaultMessage: 'Set Language'})}
name={intl.formatMessage({id: 'Sidebar.set-language', defaultMessage: 'Set language'})}
position='top'
>
<Menu.Text
@ -295,24 +299,24 @@ class Sidebar extends React.Component<Props, State> {
</Menu.SubMenu>
<Menu.SubMenu
id='theme'
name={intl.formatMessage({id: 'Sidebar.set-theme', defaultMessage: 'Set Theme'})}
name={intl.formatMessage({id: 'Sidebar.set-theme', defaultMessage: 'Set theme'})}
position='top'
>
<Menu.Text
id='default-theme'
name={intl.formatMessage({id: 'Sidebar.default-theme', defaultMessage: 'Default theme'})}
onClick={async () => setTheme(defaultTheme)}
/>
<Menu.Text
id='dark-theme'
name={intl.formatMessage({id: 'Sidebar.dark-theme', defaultMessage: 'Dark Theme'})}
name={intl.formatMessage({id: 'Sidebar.dark-theme', defaultMessage: 'Dark theme'})}
onClick={async () => setTheme(darkTheme)}
/>
<Menu.Text
id='light-theme'
name={intl.formatMessage({id: 'Sidebar.light-theme', defaultMessage: 'Light Theme'})}
name={intl.formatMessage({id: 'Sidebar.light-theme', defaultMessage: 'Light theme'})}
onClick={async () => setTheme(lightTheme)}
/>
<Menu.Text
id='mattermost-theme'
name={intl.formatMessage({id: 'Sidebar.mattermost-theme', defaultMessage: 'Mattermost Theme'})}
onClick={async () => setTheme(mattermostTheme)}
/>
</Menu.SubMenu>
</Menu>
</MenuWrapper>
@ -340,7 +344,7 @@ class Sidebar extends React.Component<Props, State> {
view.viewType = 'board'
view.parentId = board.id
view.rootId = board.rootId
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board View'})
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board view'})
await mutator.insertBlocks(
[board, view],

View File

@ -55,6 +55,8 @@
.octo-propertyvalue {
line-height: 17px;
overflow: hidden;
text-overflow: ellipsis;
}
.octo-editable,

View File

@ -29,6 +29,7 @@ type Props = {
showView: (id: string) => void
setSearchText: (text?: string) => void
intl: IntlShape
readonly: boolean
}
type State = {
@ -45,6 +46,18 @@ class TableComponent extends React.Component<Props, State> {
return true
}
componentDidMount(): void {
this.showCardInUrl()
}
private showCardInUrl() {
const queryString = new URLSearchParams(window.location.search)
const cardId = queryString.get('c') || undefined
if (cardId !== this.state.shownCardId) {
this.setState({shownCardId: cardId})
}
}
render(): JSX.Element {
const {boardTree, showView} = this.props
const {board, cards, activeView} = boardTree
@ -66,14 +79,16 @@ class TableComponent extends React.Component<Props, State> {
key={this.state.shownCardId}
boardTree={boardTree}
cardId={this.state.shownCardId}
onClose={() => this.setState({shownCardId: undefined})}
showCard={(cardId) => this.setState({shownCardId: cardId})}
onClose={() => this.showCard(undefined)}
showCard={(cardId) => this.showCard(cardId)}
readonly={this.props.readonly}
/>
</RootPortal>}
<div className='octo-frame'>
<ViewTitle
key={board.id + board.title}
board={board}
readonly={this.props.readonly}
/>
<div className='octo-table'>
@ -85,6 +100,7 @@ class TableComponent extends React.Component<Props, State> {
addCardFromTemplate={this.addCardFromTemplate}
addCardTemplate={this.addCardTemplate}
editCardTemplate={this.editCardTemplate}
readonly={this.props.readonly}
/>
{/* Main content */}
@ -103,10 +119,11 @@ class TableComponent extends React.Component<Props, State> {
className='octo-table-cell title-cell header-cell'
style={{overflow: 'unset', width: this.columnWidth(Constants.titleColumnId)}}
>
<MenuWrapper>
<MenuWrapper
disabled={this.props.readonly}
>
<div
className='octo-label'
style={{cursor: 'pointer'}}
>
<FormattedMessage
id='TableComponent.name'
@ -122,32 +139,34 @@ class TableComponent extends React.Component<Props, State> {
<div className='octo-spacer'/>
<HorizontalGrip
onDrag={(offset) => {
const originalWidth = this.columnWidth(Constants.titleColumnId)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (titleRef.current) {
titleRef.current.style.width = `${newWidth}px`
}
}}
onDragEnd={(offset) => {
Utils.log(`onDragEnd offset: ${offset}`)
const originalWidth = this.columnWidth(Constants.titleColumnId)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (titleRef.current) {
titleRef.current.style.width = `${newWidth}px`
}
{!this.props.readonly &&
<HorizontalGrip
onDrag={(offset) => {
const originalWidth = this.columnWidth(Constants.titleColumnId)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (titleRef.current) {
titleRef.current.style.width = `${newWidth}px`
}
}}
onDragEnd={(offset) => {
Utils.log(`onDragEnd offset: ${offset}`)
const originalWidth = this.columnWidth(Constants.titleColumnId)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (titleRef.current) {
titleRef.current.style.width = `${newWidth}px`
}
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[Constants.titleColumnId]) {
columnWidths[Constants.titleColumnId] = newWidth
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[Constants.titleColumnId]) {
columnWidths[Constants.titleColumnId] = newWidth
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
}
}}
/>
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
}
}}
/>
}
</div>
{/* Table header row */}
@ -187,11 +206,12 @@ class TableComponent extends React.Component<Props, State> {
this.onDropToColumn(template)
}}
>
<MenuWrapper>
<MenuWrapper
disabled={this.props.readonly}
>
<div
className='octo-label'
style={{cursor: 'pointer'}}
draggable={true}
draggable={!this.props.readonly}
onDragStart={() => {
this.draggedHeaderTemplate = template
}}
@ -210,32 +230,34 @@ class TableComponent extends React.Component<Props, State> {
<div className='octo-spacer'/>
<HorizontalGrip
onDrag={(offset) => {
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (headerRef.current) {
headerRef.current.style.width = `${newWidth}px`
}
}}
onDragEnd={(offset) => {
Utils.log(`onDragEnd offset: ${offset}`)
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (headerRef.current) {
headerRef.current.style.width = `${newWidth}px`
}
{!this.props.readonly &&
<HorizontalGrip
onDrag={(offset) => {
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (headerRef.current) {
headerRef.current.style.width = `${newWidth}px`
}
}}
onDragEnd={(offset) => {
Utils.log(`onDragEnd offset: ${offset}`)
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (headerRef.current) {
headerRef.current.style.width = `${newWidth}px`
}
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[template.id]) {
columnWidths[template.id] = newWidth
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[template.id]) {
columnWidths[template.id] = newWidth
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
}
}}
/>
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
}
}}
/>
}
</div>)
})}
</div>
@ -263,9 +285,8 @@ class TableComponent extends React.Component<Props, State> {
this.addCard(false)
}
}}
showCard={(cardId) => {
this.setState({shownCardId: cardId})
}}
showCard={this.showCard}
readonly={this.props.readonly}
/>)
this.cardIdToRowMap.set(card.id, tableRowRef)
@ -276,17 +297,19 @@ class TableComponent extends React.Component<Props, State> {
{/* Add New row */}
<div className='octo-table-footer'>
<div
className='octo-table-cell'
onClick={() => {
this.addCard()
}}
>
<FormattedMessage
id='TableComponent.plus-new'
defaultMessage='+ New'
/>
</div>
{!this.props.readonly &&
<div
className='octo-table-cell'
onClick={() => {
this.addCard()
}}
>
<FormattedMessage
id='TableComponent.plus-new'
defaultMessage='+ New'
/>
</div>
}
</div>
</div>
</div>
@ -295,6 +318,11 @@ class TableComponent extends React.Component<Props, State> {
)
}
private showCard = (cardId?: string) => {
Utils.replaceUrlQueryParam('c', cardId)
this.setState({shownCardId: cardId})
}
private columnWidth(templateId: string): number {
return Math.max(Constants.minColumnWidth, this.props.boardTree.activeView.columnWidths[templateId] || 0)
}
@ -309,10 +337,10 @@ class TableComponent extends React.Component<Props, State> {
this.props.intl.formatMessage({id: 'Mutator.new-card-from-template', defaultMessage: 'new card from template'}),
false,
async (newCardId) => {
this.setState({shownCardId: newCardId})
this.showCard(newCardId)
},
async () => {
this.setState({shownCardId: undefined})
this.showCard(undefined)
},
)
}
@ -332,7 +360,7 @@ class TableComponent extends React.Component<Props, State> {
'add card',
async () => {
if (show) {
this.setState({shownCardId: card.id})
this.showCard(card.id)
} else {
// Focus on this card's title inline on next render
this.cardIdToFocusOnRender = card.id
@ -352,15 +380,15 @@ class TableComponent extends React.Component<Props, State> {
cardTemplate,
'add card template',
async () => {
this.setState({shownCardId: cardTemplate.id})
this.showCard(cardTemplate.id)
}, async () => {
this.setState({shownCardId: undefined})
this.showCard(undefined)
},
)
}
private editCardTemplate = (cardTemplateId: string) => {
this.setState({shownCardId: cardTemplateId})
this.showCard(cardTemplateId)
}
private async onDropToColumn(template: IPropertyTemplate) {

View File

@ -19,6 +19,7 @@ type Props = {
focusOnMount: boolean
onSaveWithEnter: () => void
showCard: (cardId: string) => void
readonly: boolean
}
type State = {
@ -74,6 +75,7 @@ class TableRow extends React.Component<Props, State> {
}
}}
onCancel={() => this.setState({title: card.title})}
readonly={this.props.readonly}
/>
</div>
@ -99,7 +101,7 @@ class TableRow extends React.Component<Props, State> {
style={{width: this.columnWidth(template.id)}}
>
<PropertyValueElement
readOnly={false}
readOnly={this.props.readonly}
card={card}
boardTree={boardTree}
propertyTemplate={template}

View File

@ -15,10 +15,8 @@ import {CsvExporter} from '../csvExporter'
import mutator from '../mutator'
import {BoardTree} from '../viewModel/boardTree'
import Button from '../widgets/buttons/button'
import ButtonWithMenu from '../widgets/buttons/buttonWithMenu'
import IconButton from '../widgets/buttons/iconButton'
import CheckIcon from '../widgets/icons/check'
import DeleteIcon from '../widgets/icons/delete'
import DropdownIcon from '../widgets/icons/dropdown'
import OptionsIcon from '../widgets/icons/options'
import SortDownIcon from '../widgets/icons/sortDown'
@ -28,6 +26,7 @@ import MenuWrapper from '../widgets/menuWrapper'
import {Editable} from './editable'
import FilterComponent from './filterComponent'
import NewCardButton from './newCardButton'
import './viewHeader.scss'
type Props = {
@ -40,6 +39,7 @@ type Props = {
editCardTemplate: (cardTemplateId: string) => void
withGroupBy?: boolean
intl: IntlShape
readonly: boolean
}
type State = {
@ -65,6 +65,286 @@ class ViewHeader extends React.Component<Props, State> {
}
}
render(): JSX.Element {
const {boardTree, showView, withGroupBy, intl} = this.props
const {board, activeView} = boardTree
const hasFilter = activeView.filter && activeView.filter.filters?.length > 0
const hasSort = activeView.sortOptions.length > 0
return (
<div className='ViewHeader'>
<Editable
style={{color: 'rgb(var(--main-fg))', fontWeight: 600}}
text={activeView.title}
placeholderText='Untitled View'
onChanged={(text) => {
mutator.changeTitle(activeView, text)
}}
readonly={this.props.readonly}
/>
<MenuWrapper>
<IconButton icon={<DropdownIcon/>}/>
<ViewMenu
board={board}
boardTree={boardTree}
showView={showView}
readonly={this.props.readonly}
/>
</MenuWrapper>
<div className='octo-spacer'/>
{!this.props.readonly &&
<>
{/* Card properties */}
<MenuWrapper>
<Button>
<FormattedMessage
id='ViewHeader.properties'
defaultMessage='Properties'
/>
</Button>
<Menu>
{boardTree.board.cardProperties.map((option: IPropertyTemplate) => (
<Menu.Switch
key={option.id}
id={option.id}
name={option.name}
isOn={activeView.visiblePropertyIds.includes(option.id)}
onClick={(propertyId: string) => {
let newVisiblePropertyIds = []
if (activeView.visiblePropertyIds.includes(propertyId)) {
newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o: string) => o !== propertyId)
} else {
newVisiblePropertyIds = [...activeView.visiblePropertyIds, propertyId]
}
mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
}}
/>
))}
</Menu>
</MenuWrapper>
{/* Group by */}
{withGroupBy &&
<MenuWrapper>
<Button>
<FormattedMessage
id='ViewHeader.group-by'
defaultMessage='Group by {property}'
values={{
property: (
<span
style={{color: 'rgb(var(--main-fg))'}}
id='groupByLabel'
>
{boardTree.groupByProperty?.name}
</span>
),
}}
/>
</Button>
<Menu>
{boardTree.board.cardProperties.filter((o: IPropertyTemplate) => o.type === 'select').map((option: IPropertyTemplate) => (
<Menu.Text
key={option.id}
id={option.id}
name={option.name}
rightIcon={boardTree.activeView.groupById === option.id ? <CheckIcon/> : undefined}
onClick={(id) => {
if (boardTree.activeView.groupById === id) {
return
}
mutator.changeViewGroupById(boardTree.activeView, id)
}}
/>
))}
</Menu>
</MenuWrapper>}
{/* Filter */}
<div className='filter-container'>
<Button
active={hasFilter}
onClick={this.showFilterDialog}
>
<FormattedMessage
id='ViewHeader.filter'
defaultMessage='Filter'
/>
</Button>
{this.state.showFilter &&
<FilterComponent
boardTree={boardTree}
onClose={this.hideFilterDialog}
/>}
</div>
{/* Sort */}
<MenuWrapper>
<Button active={hasSort}>
<FormattedMessage
id='ViewHeader.sort'
defaultMessage='Sort'
/>
</Button>
<Menu>
{(activeView.sortOptions.length > 0) &&
<>
<Menu.Text
id='manual'
name='Manual'
onClick={() => {
// This sets the manual card order to the currently displayed order
// Note: Perform this as a single update to change both properties correctly
const newView = new MutableBoardView(activeView)
newView.cardOrder = boardTree.orderedCards().map((o) => o.id)
newView.sortOptions = []
mutator.updateBlock(newView, activeView, 'reorder')
}}
/>
<Menu.Text
id='revert'
name='Revert'
onClick={() => {
mutator.changeViewSortOptions(activeView, [])
}}
/>
<Menu.Separator/>
</>
}
{this.sortDisplayOptions().map((option) => {
let rightIcon: JSX.Element | undefined
if (activeView.sortOptions.length > 0) {
const sortOption = activeView.sortOptions[0]
if (sortOption.propertyId === option.id) {
rightIcon = sortOption.reversed ? <SortUpIcon/> : <SortDownIcon/>
}
}
return (
<Menu.Text
key={option.id}
id={option.id}
name={option.name}
rightIcon={rightIcon}
onClick={(propertyId: string) => {
let newSortOptions: ISortOption[] = []
if (activeView.sortOptions[0] && activeView.sortOptions[0].propertyId === propertyId) {
// Already sorting by name, so reverse it
newSortOptions = [
{propertyId, reversed: !activeView.sortOptions[0].reversed},
]
} else {
newSortOptions = [
{propertyId, reversed: false},
]
}
mutator.changeViewSortOptions(activeView, newSortOptions)
}}
/>
)
})}
</Menu>
</MenuWrapper>
</>
}
{/* Search */}
{this.state.isSearching &&
<Editable
ref={this.searchFieldRef}
text={boardTree.getSearchText()}
placeholderText={intl.formatMessage({id: 'ViewHeader.search-text', defaultMessage: 'Search text'})}
style={{color: 'rgb(var(--main-fg))'}}
onChanged={(text) => {
this.searchChanged(text)
}}
onKeyDown={(e) => {
this.onSearchKeyDown(e)
}}
/>
}
{!this.state.isSearching &&
<Button onClick={() => this.setState({isSearching: true})}>
<FormattedMessage
id='ViewHeader.search'
defaultMessage='Search'
/>
</Button>}
{/* Options menu */}
{!this.props.readonly &&
<>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='exportCsv'
name={intl.formatMessage({id: 'ViewHeader.export-csv', defaultMessage: 'Export to CSV'})}
onClick={() => CsvExporter.exportTableCsv(boardTree)}
/>
<Menu.Text
id='exportBoardArchive'
name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export board archive'})}
onClick={() => Archiver.exportBoardTree(boardTree)}
/>
{/*
<Menu.Separator/>
<Menu.Text
id='testAdd100Cards'
name={intl.formatMessage({id: 'ViewHeader.test-add-100-cards', defaultMessage: 'TEST: Add 100 cards'})}
onClick={() => this.testAddCards(100)}
/>
<Menu.Text
id='testAdd1000Cards'
name={intl.formatMessage({id: 'ViewHeader.test-add-1000-cards', defaultMessage: 'TEST: Add 1,000 cards'})}
onClick={() => this.testAddCards(1000)}
/>
<Menu.Text
id='testDistributeCards'
name={intl.formatMessage({id: 'ViewHeader.test-distribute-cards', defaultMessage: 'TEST: Distribute cards'})}
onClick={() => this.testDistributeCards()}
/>
<Menu.Text
id='testRandomizeIcons'
name={intl.formatMessage({id: 'ViewHeader.test-randomize-icons', defaultMessage: 'TEST: Randomize icons'})}
onClick={() => this.testRandomizeIcons()}
/>
*/}
</Menu>
</MenuWrapper>
{/* New card button */}
<NewCardButton
boardTree={this.props.boardTree}
addCard={this.props.addCard}
addCardFromTemplate={this.props.addCardFromTemplate}
addCardTemplate={this.props.addCardTemplate}
editCardTemplate={this.props.editCardTemplate}
/>
</>
}
</div>
)
}
private showFilterDialog = () => {
this.setState({showFilter: true})
}
@ -144,319 +424,6 @@ class ViewHeader extends React.Component<Props, State> {
})
}
render(): JSX.Element {
const {boardTree, showView, withGroupBy, intl} = this.props
const {board, activeView} = boardTree
const hasFilter = activeView.filter && activeView.filter.filters?.length > 0
const hasSort = activeView.sortOptions.length > 0
return (
<div className='ViewHeader'>
<Editable
style={{color: 'rgb(var(--main-fg))', fontWeight: 600}}
text={activeView.title}
placeholderText='Untitled View'
onChanged={(text) => {
mutator.changeTitle(activeView, text)
}}
/>
<MenuWrapper>
<IconButton icon={<DropdownIcon/>}/>
<ViewMenu
board={board}
boardTree={boardTree}
showView={showView}
/>
</MenuWrapper>
<div className='octo-spacer'/>
<MenuWrapper>
<Button>
<FormattedMessage
id='ViewHeader.properties'
defaultMessage='Properties'
/>
</Button>
<Menu>
{boardTree.board.cardProperties.map((option: IPropertyTemplate) => (
<Menu.Switch
key={option.id}
id={option.id}
name={option.name}
isOn={activeView.visiblePropertyIds.includes(option.id)}
onClick={(propertyId: string) => {
let newVisiblePropertyIds = []
if (activeView.visiblePropertyIds.includes(propertyId)) {
newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o: string) => o !== propertyId)
} else {
newVisiblePropertyIds = [...activeView.visiblePropertyIds, propertyId]
}
mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
}}
/>
))}
</Menu>
</MenuWrapper>
{withGroupBy &&
<MenuWrapper>
<Button>
<FormattedMessage
id='ViewHeader.group-by'
defaultMessage='Group by {property}'
values={{
property: (
<span
style={{color: 'rgb(var(--main-fg))'}}
id='groupByLabel'
>
{boardTree.groupByProperty?.name}
</span>
),
}}
/>
</Button>
<Menu>
{boardTree.board.cardProperties.filter((o: IPropertyTemplate) => o.type === 'select').map((option: IPropertyTemplate) => (
<Menu.Text
key={option.id}
id={option.id}
name={option.name}
rightIcon={boardTree.activeView.groupById === option.id ? <CheckIcon/> : undefined}
onClick={(id) => {
if (boardTree.activeView.groupById === id) {
return
}
mutator.changeViewGroupById(boardTree.activeView, id)
}}
/>
))}
</Menu>
</MenuWrapper>}
<div className='filter-container'>
<Button
active={hasFilter}
onClick={this.showFilterDialog}
>
<FormattedMessage
id='ViewHeader.filter'
defaultMessage='Filter'
/>
</Button>
{this.state.showFilter &&
<FilterComponent
boardTree={boardTree}
onClose={this.hideFilterDialog}
/>}
</div>
<MenuWrapper>
<Button active={hasSort}>
<FormattedMessage
id='ViewHeader.sort'
defaultMessage='Sort'
/>
</Button>
<Menu>
{(activeView.sortOptions.length > 0) &&
<>
<Menu.Text
id='manual'
name='Manual'
onClick={() => {
// This sets the manual card order to the currently displayed order
// Note: Perform this as a single update to change both properties correctly
const newView = new MutableBoardView(activeView)
newView.cardOrder = boardTree.orderedCards().map((o) => o.id)
newView.sortOptions = []
mutator.updateBlock(newView, activeView, 'reorder')
}}
/>
<Menu.Text
id='revert'
name='Revert'
onClick={() => {
mutator.changeViewSortOptions(activeView, [])
}}
/>
<Menu.Separator/>
</>
}
{this.sortDisplayOptions().map((option) => {
let rightIcon: JSX.Element | undefined
if (activeView.sortOptions.length > 0) {
const sortOption = activeView.sortOptions[0]
if (sortOption.propertyId === option.id) {
rightIcon = sortOption.reversed ? <SortUpIcon/> : <SortDownIcon/>
}
}
return (
<Menu.Text
key={option.id}
id={option.id}
name={option.name}
rightIcon={rightIcon}
onClick={(propertyId: string) => {
let newSortOptions: ISortOption[] = []
if (activeView.sortOptions[0] && activeView.sortOptions[0].propertyId === propertyId) {
// Already sorting by name, so reverse it
newSortOptions = [
{propertyId, reversed: !activeView.sortOptions[0].reversed},
]
} else {
newSortOptions = [
{propertyId, reversed: false},
]
}
mutator.changeViewSortOptions(activeView, newSortOptions)
}}
/>
)
})}
</Menu>
</MenuWrapper>
{this.state.isSearching &&
<Editable
ref={this.searchFieldRef}
text={boardTree.getSearchText()}
placeholderText={intl.formatMessage({id: 'ViewHeader.search-text', defaultMessage: 'Search text'})}
style={{color: 'rgb(var(--main-fg))'}}
onChanged={(text) => {
this.searchChanged(text)
}}
onKeyDown={(e) => {
this.onSearchKeyDown(e)
}}
/>}
{!this.state.isSearching &&
<Button onClick={() => this.setState({isSearching: true})}>
<FormattedMessage
id='ViewHeader.search'
defaultMessage='Search'
/>
</Button>}
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='exportCsv'
name={intl.formatMessage({id: 'ViewHeader.export-csv', defaultMessage: 'Export to CSV'})}
onClick={() => CsvExporter.exportTableCsv(boardTree)}
/>
<Menu.Text
id='exportBoardArchive'
name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export Board Archive'})}
onClick={() => Archiver.exportBoardTree(boardTree)}
/>
<Menu.Separator/>
<Menu.Text
id='testAdd100Cards'
name={intl.formatMessage({id: 'ViewHeader.test-add-100-cards', defaultMessage: 'TEST: Add 100 cards'})}
onClick={() => this.testAddCards(100)}
/>
<Menu.Text
id='testAdd1000Cards'
name={intl.formatMessage({id: 'ViewHeader.test-add-1000-cards', defaultMessage: 'TEST: Add 1,000 cards'})}
onClick={() => this.testAddCards(1000)}
/>
<Menu.Text
id='testDistributeCards'
name={intl.formatMessage({id: 'ViewHeader.test-distribute-cards', defaultMessage: 'TEST: Distribute cards'})}
onClick={() => this.testDistributeCards()}
/>
<Menu.Text
id='testRandomizeIcons'
name={intl.formatMessage({id: 'ViewHeader.test-randomize-icons', defaultMessage: 'TEST: Randomize icons'})}
onClick={() => this.testRandomizeIcons()}
/>
</Menu>
</MenuWrapper>
<ButtonWithMenu
onClick={() => {
this.props.addCard()
}}
text={(
<FormattedMessage
id='ViewHeader.new'
defaultMessage='New'
/>
)}
>
<Menu position='left'>
<Menu.Label>
<b>
<FormattedMessage
id='ViewHeader.select-a-template'
defaultMessage='Select a template'
/>
</b>
</Menu.Label>
<Menu.Separator/>
{boardTree.cardTemplates.map((cardTemplate) => {
let displayName = cardTemplate.title || intl.formatMessage({id: 'ViewHeader.untitled', defaultMessage: 'Untitled'})
if (cardTemplate.icon) {
displayName = `${cardTemplate.icon} ${displayName}`
}
return (
<Menu.Text
key={cardTemplate.id}
id={cardTemplate.id}
name={displayName}
onClick={() => {
this.props.addCardFromTemplate(cardTemplate.id)
}}
rightIcon={
<MenuWrapper stopPropagationOnToggle={true}>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='left'>
<Menu.Text
id='edit'
name={intl.formatMessage({id: 'ViewHeader.edit-template', defaultMessage: 'Edit'})}
onClick={() => {
this.props.editCardTemplate(cardTemplate.id)
}}
/>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'ViewHeader.delete-template', defaultMessage: 'Delete'})}
onClick={async () => {
await mutator.deleteBlock(cardTemplate, 'delete card template')
}}
/>
</Menu>
</MenuWrapper>
}
/>
)
})}
<Menu.Text
id='empty-template'
name={intl.formatMessage({id: 'ViewHeader.empty-card', defaultMessage: 'Empty card'})}
onClick={() => {
this.props.addCard()
}}
/>
<Menu.Text
id='add-template'
name={intl.formatMessage({id: 'ViewHeader.add-template', defaultMessage: '+ New template'})}
onClick={() => this.props.addCardTemplate()}
/>
</Menu>
</ButtonWithMenu>
</div>
)
}
private sortDisplayOptions() {
const {boardTree} = this.props

View File

@ -4,11 +4,16 @@ import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {Board} from '../blocks/board'
import {MutableBoardView} from '../blocks/boardView'
import {IViewType, MutableBoardView} from '../blocks/boardView'
import {Constants} from '../constants'
import mutator from '../mutator'
import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree'
import AddIcon from '../widgets/icons/add'
import BoardIcon from '../widgets/icons/board'
import DeleteIcon from '../widgets/icons/delete'
import DuplicateIcon from '../widgets/icons/duplicate'
import TableIcon from '../widgets/icons/table'
import Menu from '../widgets/menu'
type Props = {
@ -16,9 +21,31 @@ type Props = {
board: Board,
showView: (id: string) => void
intl: IntlShape
readonly: boolean
}
export class ViewMenu extends React.PureComponent<Props> {
private handleDuplicateView = async () => {
const {boardTree, showView} = this.props
Utils.log('duplicateView')
const currentViewId = boardTree.activeView.id
const newView = boardTree.activeView.duplicate()
newView.title = `Copy of ${boardTree.activeView.title}`
await mutator.insertBlock(
newView,
'duplicate view',
async () => {
// This delay is needed because OctoListener has a default 100 ms notification delay before updates
setTimeout(() => {
showView(newView.id)
}, 120)
},
async () => {
showView(currentViewId)
},
)
}
private handleDeleteView = async () => {
const {boardTree, showView} = this.props
Utils.log('deleteView')
@ -44,7 +71,7 @@ export class ViewMenu extends React.PureComponent<Props> {
const {board, boardTree, showView, intl} = this.props
Utils.log('addview-board')
const view = new MutableBoardView()
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board View'})
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board view'})
view.viewType = 'board'
view.parentId = board.id
view.rootId = board.rootId
@ -55,7 +82,10 @@ export class ViewMenu extends React.PureComponent<Props> {
view,
'add view',
async () => {
showView(view.id)
// This delay is needed because OctoListener has a default 100 ms notification delay before updates
setTimeout(() => {
showView(view.id)
}, 120)
},
async () => {
showView(oldViewId)
@ -67,7 +97,7 @@ export class ViewMenu extends React.PureComponent<Props> {
Utils.log('addview-table')
const view = new MutableBoardView()
view.title = intl.formatMessage({id: 'View.NewTableTitle', defaultMessage: 'Table View'})
view.title = intl.formatMessage({id: 'View.NewTableTitle', defaultMessage: 'Table view'})
view.viewType = 'table'
view.parentId = board.id
view.rootId = board.rootId
@ -81,7 +111,11 @@ export class ViewMenu extends React.PureComponent<Props> {
view,
'add view',
async () => {
showView(view.id)
// This delay is needed because OctoListener has a default 100 ms notification delay before updates
setTimeout(() => {
Utils.log(`showView: ${view.id}`)
showView(view.id)
}, 120)
},
async () => {
showView(oldViewId)
@ -97,33 +131,57 @@ export class ViewMenu extends React.PureComponent<Props> {
key={view.id}
id={view.id}
name={view.title}
icon={this.iconForViewType(view.viewType)}
onClick={this.handleViewClick}
/>))}
<Menu.Separator/>
{boardTree.views.length > 1 &&
{!this.props.readonly &&
<Menu.Text
id='__duplicateView'
name='Duplicate View'
icon={<DuplicateIcon/>}
onClick={this.handleDuplicateView}
/>
}
{!this.props.readonly && boardTree.views.length > 1 &&
<Menu.Text
id='__deleteView'
name='Delete View'
icon={<DeleteIcon/>}
onClick={this.handleDeleteView}
/>}
<Menu.SubMenu
id='__addView'
name='Add View'
>
<Menu.Text
id='board'
name='Board'
onClick={this.handleAddViewBoard}
/>
<Menu.Text
id='table'
name='Table'
onClick={this.handleAddViewTable}
/>
</Menu.SubMenu>
}
{!this.props.readonly &&
<Menu.SubMenu
id='__addView'
name='Add View'
icon={<AddIcon/>}
>
<Menu.Text
id='board'
name='Board'
icon={<BoardIcon/>}
onClick={this.handleAddViewBoard}
/>
<Menu.Text
id='table'
name='Table'
icon={<TableIcon/>}
onClick={this.handleAddViewTable}
/>
</Menu.SubMenu>
}
</Menu>
)
}
private iconForViewType(viewType: IViewType) {
switch (viewType) {
case 'board': return <BoardIcon/>
case 'table': return <TableIcon/>
default: return <div/>
}
}
}
export default injectIntl(ViewMenu)

View File

@ -19,6 +19,7 @@ import './viewTitle.scss'
type Props = {
board: Board
intl: IntlShape
readonly: boolean
}
type State = {
@ -42,7 +43,7 @@ class ViewTitle extends React.Component<Props, State> {
return (
<>
<div className='ViewTitle add-buttons add-visible'>
{!board.icon &&
{!this.props.readonly && !board.icon &&
<Button
onClick={() => {
const newIcon = BlockIcons.shared.randomIcon()
@ -52,11 +53,11 @@ class ViewTitle extends React.Component<Props, State> {
>
<FormattedMessage
id='TableComponent.add-icon'
defaultMessage='Add Icon'
defaultMessage='Add icon'
/>
</Button>
}
{board.showDescription &&
{!this.props.readonly && board.showDescription &&
<Button
onClick={() => {
mutator.showDescription(board, false)
@ -69,7 +70,7 @@ class ViewTitle extends React.Component<Props, State> {
/>
</Button>
}
{!board.showDescription &&
{!this.props.readonly && !board.showDescription &&
<Button
onClick={() => {
mutator.showDescription(board, true)
@ -90,11 +91,12 @@ class ViewTitle extends React.Component<Props, State> {
ref={this.titleEditor}
className='title'
value={this.state.title}
placeholderText={intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled Board'})}
placeholderText={intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'})}
onChange={(title) => this.setState({title})}
saveOnEsc={true}
onSave={() => mutator.changeTitle(board, this.state.title)}
onCancel={() => this.setState({title: this.props.board.title})}
readonly={this.props.readonly}
/>
</div>
@ -106,6 +108,7 @@ class ViewTitle extends React.Component<Props, State> {
onBlur={(text) => {
mutator.changeDescription(board, text)
}}
readonly={this.props.readonly}
/>
</div>
}

View File

@ -15,10 +15,11 @@ import './workspaceComponent.scss'
type Props = {
workspaceTree: WorkspaceTree
boardTree?: BoardTree
showBoard: (id: string) => void
showBoard: (id?: string) => void
showView: (id: string, boardId?: string) => void
setSearchText: (text?: string) => void
setLanguage: (lang: string) => void
readonly: boolean
}
class WorkspaceComponent extends React.PureComponent<Props> {
@ -28,13 +29,15 @@ class WorkspaceComponent extends React.PureComponent<Props> {
Utils.assert(workspaceTree)
const element = (
<div className='WorkspaceComponent'>
<Sidebar
showBoard={showBoard}
showView={showView}
workspaceTree={workspaceTree}
activeBoardId={boardTree?.board.id}
setLanguage={setLanguage}
/>
{!this.props.readonly &&
<Sidebar
showBoard={showBoard}
showView={showView}
workspaceTree={workspaceTree}
activeBoardId={boardTree?.board.id}
setLanguage={setLanguage}
/>
}
<div className='mainFrame'>
{(boardTree?.board.isTemplate) &&
<div className='banner'>
@ -66,6 +69,7 @@ class WorkspaceComponent extends React.PureComponent<Props> {
boardTree={boardTree}
setSearchText={setSearchText}
showView={showView}
readonly={this.props.readonly}
/>)
}
@ -75,6 +79,7 @@ class WorkspaceComponent extends React.PureComponent<Props> {
boardTree={boardTree}
setSearchText={setSearchText}
showView={showView}
readonly={this.props.readonly}
/>)
}

View File

@ -1,17 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BlockIcons} from './blockIcons'
import {IBlock, MutableBlock} from './blocks/block'
import {Board, IPropertyOption, IPropertyTemplate, MutableBoard, PropertyType} from './blocks/board'
import {BoardView, ISortOption, MutableBoardView} from './blocks/boardView'
import {Card, MutableCard} from './blocks/card'
import {MutableImageBlock} from './blocks/imageBlock'
import {IOrderedBlock, MutableOrderedBlock} from './blocks/orderedBlock'
import {BoardTree} from './viewModel/boardTree'
import {FilterGroup} from './filterGroup'
import octoClient from './octoClient'
import {OctoUtils} from './octoUtils'
import undoManager from './undomanager'
import {Utils} from './utils'
import {OctoUtils} from './octoUtils'
import {BoardTree} from './viewModel/boardTree'
//
// The Mutator is used to make all changes to server state
@ -172,10 +172,10 @@ class Mutator {
await this.updateBlock(newBoard, board, actionDescription)
}
async changeOrder(block: IOrderedBlock, order: number, description = 'change order') {
const newBlock = new MutableOrderedBlock(block)
newBlock.order = order
await this.updateBlock(newBlock, block, description)
async changeCardContentOrder(card: Card, contentOrder: string[], description = 'reorder'): Promise<void> {
const newCard = new MutableCard(card)
newCard.contentOrder = contentOrder
await this.updateBlock(newCard, card, description)
}
// Property Templates
@ -446,6 +446,8 @@ class Mutator {
async changeViewGroupById(view: BoardView, groupById: string): Promise<void> {
const newView = new MutableBoardView(view)
newView.groupById = groupById
newView.hiddenOptionIds = []
newView.visibleOptionIds = []
await this.updateBlock(newView, view, 'group by')
}
@ -512,6 +514,7 @@ class Mutator {
const newBlocks = newBlocks1.filter((o) => o.type !== 'comment')
Utils.log(`duplicateCard: duplicating ${newBlocks.length} blocks`)
if (asTemplate === newCard.isTemplate) {
// Copy template
newCard.title = `Copy of ${newCard.title}`
} else if (asTemplate) {
// Template from card
@ -519,6 +522,11 @@ class Mutator {
} else {
// Card from template
newCard.title = ''
// If the template doesn't specify an icon, initialize it to a random one
if (!newCard.icon) {
newCard.icon = BlockIcons.shared.randomIcon()
}
}
newCard.isTemplate = asTemplate
await this.insertBlocks(
@ -551,7 +559,6 @@ class Mutator {
newBoard.title = 'New board template'
} else {
// Board from template
newBoard.title = ''
}
newBoard.isTemplate = asTemplate
await this.insertBlocks(
@ -577,7 +584,7 @@ class Mutator {
return octoClient.importFullArchive(blocks)
}
async createImageBlock(parent: IBlock, file: File, order = 1000): Promise<IBlock | undefined> {
async createImageBlock(parent: IBlock, file: File, description = 'add image'): Promise<IBlock | undefined> {
const url = await octoClient.uploadFile(file)
if (!url) {
return undefined
@ -586,7 +593,6 @@ class Mutator {
const block = new MutableImageBlock()
block.parentId = parent.id
block.rootId = parent.rootId
block.order = order
block.url = url
await undoManager.perform(
@ -596,7 +602,7 @@ class Mutator {
async () => {
await octoClient.deleteBlock(block.id)
},
'add image',
description,
this.undoGroupId,
)

View File

@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Disable console log
console.log = jest.fn()
import {IBlock} from './blocks/block'
import {MutableBoard} from './blocks/board'
import octoClient from './octoClient'
import 'isomorphic-fetch'
import {FetchMock} from './test/fetchMock'
global.fetch = FetchMock.fn
beforeEach(() => {
FetchMock.fn.mockReset()
})
test('OctoClient: get blocks', async () => {
const blocks = createBoards()
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify(blocks)))
let boards = await octoClient.getBlocksWithType('board')
expect(boards.length).toBe(blocks.length)
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify(blocks)))
boards = await octoClient.getSubtree()
expect(boards.length).toBe(blocks.length)
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify(blocks)))
boards = await octoClient.exportFullArchive()
expect(boards.length).toBe(blocks.length)
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify(blocks)))
const parentId = 'id1'
boards = await octoClient.getBlocksWithParent(parentId)
expect(boards.length).toBe(blocks.length)
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify(blocks)))
boards = await octoClient.getBlocksWithParent(parentId, 'board')
expect(boards.length).toBe(blocks.length)
})
test('OctoClient: insert blocks', async () => {
const blocks = createBoards()
await octoClient.insertBlocks(blocks)
expect(FetchMock.fn).toBeCalledTimes(1)
expect(FetchMock.fn).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'POST',
body: JSON.stringify(blocks),
}))
})
test('OctoClient: importFullArchive', async () => {
const blocks = createBoards()
await octoClient.importFullArchive(blocks)
expect(FetchMock.fn).toBeCalledTimes(1)
expect(FetchMock.fn).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'POST',
body: JSON.stringify(blocks),
}))
})
function createBoards(): IBlock[] {
const blocks = []
for (let i = 0; i < 5; i++) {
const board = new MutableBoard()
board.id = `board${i + 1}`
blocks.push(board)
}
return blocks
}

View File

@ -8,7 +8,6 @@ import {MutableCard} from './blocks/card'
import {MutableCommentBlock} from './blocks/commentBlock'
import {MutableDividerBlock} from './blocks/dividerBlock'
import {MutableImageBlock} from './blocks/imageBlock'
import {IOrderedBlock} from './blocks/orderedBlock'
import {MutableTextBlock} from './blocks/textBlock'
import {Utils} from './utils'
@ -42,22 +41,27 @@ class OctoUtils {
return displayValue
}
static getOrderBefore(block: IOrderedBlock, blocks: readonly IOrderedBlock[]): number {
const index = blocks.indexOf(block)
if (index === 0) {
return block.order / 2
static relativeBlockOrder(partialOrder: readonly string[], blocks: readonly IBlock[], blockA: IBlock, blockB: IBlock): number {
const orderA = partialOrder.indexOf(blockA.id)
const orderB = partialOrder.indexOf(blockB.id)
if (orderA >= 0 && orderB >= 0) {
// Order of both blocks is specified
return orderA - orderB
}
const previousBlock = blocks[index - 1]
return (block.order + previousBlock.order) / 2
if (orderA >= 0) {
return -1
}
if (orderB >= 0) {
return 1
}
// Order of both blocks are unspecified, use create date
return blockA.createAt - blockB.createAt
}
static getOrderAfter(block: IOrderedBlock, blocks: readonly IOrderedBlock[]): number {
const index = blocks.indexOf(block)
if (index === blocks.length - 1) {
return block.order + 1000
}
const nextBlock = blocks[index + 1]
return (block.order + nextBlock.order) / 2
static getBlockOrder(partialOrder: readonly string[], blocks: readonly IBlock[]): IBlock[] {
return blocks.slice().sort((a, b) => this.relativeBlockOrder(partialOrder, blocks, a, b))
}
static hydrateBlock(block: IBlock): MutableBlock {
@ -76,11 +80,11 @@ class OctoUtils {
}
}
static hydrateBlocks(blocks: IBlock[]): MutableBlock[] {
static hydrateBlocks(blocks: readonly IBlock[]): MutableBlock[] {
return blocks.map((block) => this.hydrateBlock(block))
}
static mergeBlocks(blocks: IBlock[], updatedBlocks: IBlock[]): IBlock[] {
static mergeBlocks(blocks: readonly IBlock[], updatedBlocks: readonly IBlock[]): IBlock[] {
const updatedBlockIds = updatedBlocks.map((o) => o.id)
const newBlocks = blocks.filter((o) => !updatedBlockIds.includes(o.id))
const updatedAndNotDeletedBlocks = updatedBlocks.filter((o) => o.deleteAt === 0)
@ -89,7 +93,7 @@ class OctoUtils {
}
// Creates a copy of the blocks with new ids and parentIDs
static duplicateBlockTree(blocks: IBlock[], sourceBlockId: string): [MutableBlock[], MutableBlock, Readonly<Record<string, string>>] {
static duplicateBlockTree(blocks: readonly IBlock[], sourceBlockId: string): [MutableBlock[], MutableBlock, Readonly<Record<string, string>>] {
const idMap: Record<string, string> = {}
const newBlocks = blocks.map((block) => {
const newBlock = this.hydrateBlock(block)
@ -126,6 +130,12 @@ class OctoUtils {
const view = newBlock as MutableBoardView
view.cardOrder = view.cardOrder.map((o) => idMap[o])
}
// Remap card content order
if (newBlock.type === 'card') {
const card = newBlock as MutableCard
card.contentOrder = card.contentOrder.map((o) => idMap[o])
}
})
const newSourceBlock = newBlocks.find((block) => block.id === newSourceBlockId)!

View File

@ -20,6 +20,7 @@ type State = {
viewId: string
workspaceTree: WorkspaceTree
boardTree?: BoardTree
readonly: boolean
}
export default class BoardPage extends React.Component<Props, State> {
@ -30,11 +31,13 @@ export default class BoardPage extends React.Component<Props, State> {
const queryString = new URLSearchParams(window.location.search)
const boardId = queryString.get('id') || ''
const viewId = queryString.get('v') || ''
const readonly = (queryString.get('r') === '1')
this.state = {
boardId,
viewId,
workspaceTree: new MutableWorkspaceTree(),
readonly,
}
Utils.log(`BoardPage. boardId: ${boardId}`)
@ -56,7 +59,15 @@ export default class BoardPage extends React.Component<Props, State> {
Utils.setFavicon(board?.icon)
}
if (board?.title !== prevBoard?.title || activeView?.title !== prevActiveView?.title) {
document.title = `${board?.title} | ${activeView?.title}`
if (board) {
let title = `${board.title}`
if (activeView?.title) {
title += ` | ${activeView.title}`
}
document.title = title
} else {
document.title = 'OCTO'
}
}
}
@ -65,6 +76,10 @@ export default class BoardPage extends React.Component<Props, State> {
return
}
if (this.state.readonly) {
return
}
if (e.keyCode === 90 && !e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) { // Cmd+Z
Utils.log('Undo')
if (mutator.canUndo) {
@ -128,21 +143,30 @@ export default class BoardPage extends React.Component<Props, State> {
this.setSearchText(text)
}}
setLanguage={this.props.setLanguage}
readonly={this.state.readonly}
/>
</div>
)
}
private async attachToBoard(boardId: string, viewId?: string) {
private async attachToBoard(boardId?: string, viewId = '') {
Utils.log(`attachToBoard: ${boardId}`)
this.sync(boardId, viewId)
if (boardId) {
this.sync(boardId, viewId)
} else {
// No board
this.setState({
boardTree: undefined,
boardId: '',
viewId: '',
})
}
}
private async sync(boardId: string = this.state.boardId, viewId: string | undefined = this.state.viewId) {
Utils.log(`sync start: ${boardId}`)
const workspaceTree = new MutableWorkspaceTree()
await workspaceTree.sync()
const workspaceTree = await MutableWorkspaceTree.sync()
const boardIds = [...workspaceTree.boards.map((o) => o.id), ...workspaceTree.boardTemplates.map((o) => o.id)]
this.setState({workspaceTree})
@ -160,51 +184,81 @@ export default class BoardPage extends React.Component<Props, State> {
)
if (boardId) {
const boardTree = new MutableBoardTree(boardId)
await boardTree.sync()
const boardTree = await MutableBoardTree.sync(boardId, viewId)
// Default to first view
boardTree.setActiveView(viewId || boardTree.views[0].id)
if (boardTree && boardTree.board) {
// Update url with viewId if it's different
if (boardTree.activeView.id !== this.state.viewId) {
Utils.replaceUrlQueryParam('v', boardTree.activeView.id)
}
// TODO: Handle error (viewId not found)
// TODO: Handle error (viewId not found)
this.setState({
boardTree,
boardId,
viewId: boardTree.activeView!.id,
})
Utils.log(`sync complete: ${boardTree.board.id} (${boardTree.board.title})`)
this.setState({
boardTree,
boardId,
viewId: boardTree.activeView!.id,
})
Utils.log(`sync complete: ${boardTree.board?.id} (${boardTree.board?.title})`)
} else {
// Board may have been deleted
this.setState({
boardTree: undefined,
viewId: '',
})
Utils.log(`sync complete: board ${boardId} not found`)
}
}
}
private incrementalUpdate(blocks: IBlock[]) {
const {workspaceTree, boardTree} = this.state
private async incrementalUpdate(blocks: IBlock[]) {
const {workspaceTree, boardTree, viewId} = this.state
let newState = {workspaceTree, boardTree}
let newState = {workspaceTree, boardTree, viewId}
const newWorkspaceTree = workspaceTree.mutableCopy()
if (newWorkspaceTree.incrementalUpdate(blocks)) {
const newWorkspaceTree = MutableWorkspaceTree.incrementalUpdate(workspaceTree, blocks)
if (newWorkspaceTree) {
newState = {...newState, workspaceTree: newWorkspaceTree}
}
const newBoardTree = boardTree ? boardTree.mutableCopy() : new MutableBoardTree(this.state.boardId)
if (newBoardTree.incrementalUpdate(blocks)) {
newBoardTree.setActiveView(this.state.viewId)
newState = {...newState, boardTree: newBoardTree}
let newBoardTree: BoardTree | undefined
if (boardTree) {
newBoardTree = MutableBoardTree.incrementalUpdate(boardTree, blocks)
} else if (this.state.boardId) {
// Corner case: When the page is viewing a deleted board, that is subsequently un-deleted on another client
newBoardTree = await MutableBoardTree.sync(this.state.boardId, this.state.viewId)
}
if (newBoardTree) {
newState = {...newState, boardTree: newBoardTree, viewId: newBoardTree.activeView.id}
} else {
newState = {...newState, boardTree: undefined}
}
// Update url with viewId if it's different
if (newBoardTree && newBoardTree.activeView.id !== this.state.viewId) {
Utils.replaceUrlQueryParam('v', newBoardTree?.activeView.id)
}
this.setState(newState)
}
// IPageController
showBoard(boardId: string): void {
showBoard(boardId?: string): void {
const {boardTree} = this.state
if (boardTree?.board?.id === boardId) {
return
}
const newUrl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?id=${encodeURIComponent(boardId)}`
let newUrl = window.location.protocol + '//' + window.location.host + window.location.pathname
if (boardId) {
newUrl += `?id=${encodeURIComponent(boardId)}`
if (this.state.readonly) {
newUrl += '&r=1'
}
}
window.history.pushState({path: newUrl}, '', newUrl)
this.attachToBoard(boardId)
@ -212,14 +266,16 @@ export default class BoardPage extends React.Component<Props, State> {
showView(viewId: string, boardId: string = this.state.boardId): void {
if (this.state.boardTree && this.state.boardId === boardId) {
const newBoardTree = this.state.boardTree.mutableCopy()
newBoardTree.setActiveView(viewId)
const newBoardTree = this.state.boardTree.copyWithView(viewId)
this.setState({boardTree: newBoardTree, viewId})
} else {
this.attachToBoard(boardId, viewId)
}
const newUrl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?id=${encodeURIComponent(boardId)}&v=${encodeURIComponent(viewId)}`
let newUrl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?id=${encodeURIComponent(boardId)}&v=${encodeURIComponent(viewId)}`
if (this.state.readonly) {
newUrl += '&r=1'
}
window.history.pushState({path: newUrl}, '', newUrl)
}
@ -229,8 +285,7 @@ export default class BoardPage extends React.Component<Props, State> {
return
}
const newBoardTree = this.state.boardTree.mutableCopy()
newBoardTree.setSearchText(text)
const newBoardTree = this.state.boardTree.copyWithSearchText(text)
this.setState({boardTree: newBoardTree})
}
}

View File

@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
class FetchMock {
static fn = jest.fn(async () => {
const response = new Response()
return response
})
static async jsonResponse(json: string): Promise<Response> {
const response = new Response(json)
return response
}
}
export {FetchMock}

View File

@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Board, IPropertyOption, IPropertyTemplate, MutableBoard} from '../blocks/board'
import {MutableBoardView} from '../blocks/boardView'
import {Card, MutableCard} from '../blocks/card'
import {MutableCommentBlock} from '../blocks/commentBlock'
import {DividerBlock, MutableDividerBlock} from '../blocks/dividerBlock'
import {ImageBlock, MutableImageBlock} from '../blocks/imageBlock'
import {MutableTextBlock, TextBlock} from '../blocks/textBlock'
import {FilterClause} from '../filterClause'
import {FilterGroup} from '../filterGroup'
class TestBlockFactory {
static createBoard(): MutableBoard {
const board = new MutableBoard()
board.rootId = board.id
board.title = 'board title'
board.description = 'description'
board.showDescription = true
board.icon = 'i'
for (let i = 0; i < 3; i++) {
const propertyOption: IPropertyOption = {
id: 'value1',
value: 'value 1',
color: 'color1',
}
const propertyTemplate: IPropertyTemplate = {
id: `property${i + 1}`,
name: `Property ${i + 1}`,
type: 'select',
options: [propertyOption],
}
board.cardProperties.push(propertyTemplate)
}
return board
}
static createBoardView(board?: Board): MutableBoardView {
const view = new MutableBoardView()
view.parentId = board ? board.id : 'parent'
view.rootId = board ? board.rootId : 'root'
view.title = 'view title'
view.viewType = 'board'
view.groupById = 'property1'
view.hiddenOptionIds = ['value1']
view.cardOrder = ['card1', 'card2', 'card3']
view.sortOptions = [
{
propertyId: 'property1',
reversed: true,
},
{
propertyId: 'property2',
reversed: false,
},
]
view.columnWidths = {
column1: 100,
column2: 200,
}
// Filter
const filterGroup = new FilterGroup()
const filter = new FilterClause()
filter.propertyId = 'property1'
filter.condition = 'includes'
filter.values = ['value1']
filterGroup.filters.push(filter)
view.filter = filterGroup
return view
}
static createCard(board?: Board): MutableCard {
const card = new MutableCard()
card.parentId = board ? board.id : 'parent'
card.rootId = board ? board.rootId : 'root'
card.title = 'title'
card.icon = 'i'
card.properties.property1 = 'value1'
return card
}
static createComment(card: Card): MutableCommentBlock {
const block = new MutableCommentBlock()
block.parentId = card.id
block.rootId = card.rootId
block.title = 'title'
return block
}
static createText(card: Card): TextBlock {
const block = new MutableTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.title = 'title'
return block
}
static createImage(card: Card): ImageBlock {
const block = new MutableImageBlock()
block.parentId = card.id
block.rootId = card.rootId
block.url = 'url'
return block
}
static createDivider(card: Card): DividerBlock {
const block = new MutableDividerBlock()
block.parentId = card.id
block.rootId = card.rootId
block.title = 'title'
return block
}
}
export {TestBlockFactory}

View File

@ -10,6 +10,15 @@ export type Theme = {
sidebarFg: string
}
export const defaultTheme = {
mainBg: '255, 255, 255',
mainFg: '55, 53, 47',
buttonBg: '22, 109, 224',
buttonFg: '255, 255, 255',
sidebarBg: '20, 93, 191',
sidebarFg: '255, 255, 255',
}
export const darkTheme = {
mainBg: '55, 53, 47',
mainFg: '200, 200, 200',
@ -28,15 +37,6 @@ export const lightTheme = {
sidebarFg: '55, 53, 47',
}
export const mattermostTheme = {
mainBg: '255, 255, 255',
mainFg: '55, 53, 47',
buttonBg: '22, 109, 224',
buttonFg: '255, 255, 255',
sidebarBg: '20, 93, 191',
sidebarFg: '255, 255, 255',
}
export function setTheme(theme: Theme): void {
document.documentElement.style.setProperty('--main-bg', theme.mainBg)
document.documentElement.style.setProperty('--main-fg', theme.mainFg)
@ -54,9 +54,9 @@ export function loadTheme(): void {
const theme = JSON.parse(themeStr)
setTheme(theme)
} catch (e) {
setTheme(lightTheme)
setTheme(defaultTheme)
}
} else {
setTheme(lightTheme)
setTheme(defaultTheme)
}
}

15
webapp/src/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": [
"../node_modules/*",
"../@custom_types/*"
]
}
},
"include": [
"./**/*.ts"
]
}

View File

@ -2,7 +2,6 @@
// See LICENSE.txt for license information.
import undoManager from './undomanager'
import {Utils} from './utils'
test('Basic undo/redo', async () => {
expect(undoManager.canUndo).toBe(false)
@ -24,21 +23,21 @@ test('Basic undo/redo', async () => {
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(false)
expect(undoManager.currentCheckpoint).toBeGreaterThan(0)
expect(Utils.arraysEqual(values, ['a'])).toBe(true)
expect(values).toEqual(['a'])
expect(undoManager.undoDescription).toBe('test')
expect(undoManager.redoDescription).toBe(undefined)
await undoManager.undo()
expect(undoManager.canUndo).toBe(false)
expect(undoManager.canRedo).toBe(true)
expect(Utils.arraysEqual(values, [])).toBe(true)
expect(values).toEqual([])
expect(undoManager.undoDescription).toBe(undefined)
expect(undoManager.redoDescription).toBe('test')
await undoManager.redo()
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(false)
expect(Utils.arraysEqual(values, ['a'])).toBe(true)
expect(values).toEqual(['a'])
await undoManager.clear()
expect(undoManager.canUndo).toBe(false)
@ -67,7 +66,7 @@ test('Grouped undo/redo', async () => {
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(false)
expect(Utils.arraysEqual(values, ['a'])).toBe(true)
expect(values).toEqual(['a'])
expect(undoManager.undoDescription).toBe('insert a')
expect(undoManager.redoDescription).toBe(undefined)
@ -84,7 +83,7 @@ test('Grouped undo/redo', async () => {
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(false)
expect(Utils.arraysEqual(values, ['a', 'b'])).toBe(true)
expect(values).toEqual(['a', 'b'])
expect(undoManager.undoDescription).toBe('insert b')
expect(undoManager.redoDescription).toBe(undefined)
@ -101,21 +100,21 @@ test('Grouped undo/redo', async () => {
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(false)
expect(Utils.arraysEqual(values, ['a', 'b', 'c'])).toBe(true)
expect(values).toEqual(['a', 'b', 'c'])
expect(undoManager.undoDescription).toBe('insert c')
expect(undoManager.redoDescription).toBe(undefined)
await undoManager.undo()
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(true)
expect(Utils.arraysEqual(values, ['a'])).toBe(true)
expect(values).toEqual(['a'])
expect(undoManager.undoDescription).toBe('insert a')
expect(undoManager.redoDescription).toBe('insert b')
await undoManager.redo()
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(false)
expect(Utils.arraysEqual(values, ['a', 'b', 'c'])).toBe(true)
expect(values).toEqual(['a', 'b', 'c'])
expect(undoManager.undoDescription).toBe('insert c')
expect(undoManager.redoDescription).toBe(undefined)

View File

@ -76,6 +76,10 @@ class Utils {
return text
}
static sleep(miliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, miliseconds))
}
// Errors
static assertValue(valueObject: any): void {
@ -131,6 +135,22 @@ class Utils {
document.getElementsByTagName('head')[0].appendChild(link)
}
// URL
static replaceUrlQueryParam(paramName: string, value?: string): void {
const queryString = new URLSearchParams(window.location.search)
const currentValue = queryString.get(paramName) || ''
if (currentValue !== value) {
const newUrl = new URL(window.location.toString())
if (value) {
newUrl.searchParams.set(paramName, value)
} else {
newUrl.searchParams.delete(paramName)
}
window.history.pushState({}, document.title, newUrl.toString())
}
}
// File names
static sanitizeFilename(filename: string): string {
@ -163,7 +183,7 @@ class Utils {
// Arrays
static arraysEqual(a: any[], b: any[]): boolean {
static arraysEqual(a: readonly any[], b: readonly any[]): boolean {
if (a === b) {
return true
}
@ -184,6 +204,10 @@ class Utils {
}
return true
}
static arrayMove(arr: any[], srcIndex: number, destIndex: number): void {
arr.splice(destIndex, 0, arr.splice(srcIndex, 1)[0])
}
}
export {Utils}

View File

@ -0,0 +1,146 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Disable console log
console.log = jest.fn()
console.error = jest.fn()
import 'isomorphic-fetch'
import {TestBlockFactory} from '../test/testBlockFactory'
import {FetchMock} from '../test/fetchMock'
import {BoardTree, MutableBoardTree} from './boardTree'
global.fetch = FetchMock.fn
beforeEach(() => {
FetchMock.fn.mockReset()
})
test('BoardTree', async () => {
const board = TestBlockFactory.createBoard()
const view = TestBlockFactory.createBoardView(board)
const view2 = TestBlockFactory.createBoardView(board)
view2.sortOptions = []
const card = TestBlockFactory.createCard(board)
const cardTemplate = TestBlockFactory.createCard(board)
cardTemplate.isTemplate = true
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
let boardTree: BoardTree | undefined
boardTree = await MutableBoardTree.sync('invalid_id', 'invalid_id')
expect(boardTree).toBeUndefined()
expect(FetchMock.fn).toBeCalledTimes(1)
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
boardTree = await MutableBoardTree.sync(board.id, view.id)
expect(boardTree).not.toBeUndefined()
if (!boardTree) {
fail('sync')
}
expect(FetchMock.fn).toBeCalledTimes(2)
expect(boardTree.board).toEqual(board)
expect(boardTree.views).toEqual([view, view2])
expect(boardTree.allCards).toEqual([card])
expect(boardTree.orderedCards()).toEqual([card])
expect(boardTree.cardTemplates).toEqual([cardTemplate])
expect(boardTree.allBlocks).toEqual([board, view, view2, card, cardTemplate])
// Group / filter with sort
expect(boardTree.activeView).toEqual(view)
expect(boardTree.cards).toEqual([card])
// Group / filter without sort
boardTree = boardTree.copyWithView(view2.id)
expect(boardTree.activeView).toEqual(view2)
expect(boardTree.cards).toEqual([card])
// Invalid view, defaults to first view
boardTree = boardTree.copyWithView('invalid id')
expect(boardTree.activeView).toEqual(view)
// Incremental update
const view3 = TestBlockFactory.createBoardView(board)
const card2 = TestBlockFactory.createCard(board)
const cardTemplate2 = TestBlockFactory.createCard(board)
cardTemplate2.isTemplate = true
let originalBoardTree = boardTree
boardTree = MutableBoardTree.incrementalUpdate(boardTree, [view3, card2, cardTemplate2])
expect(boardTree).not.toBe(originalBoardTree)
expect(boardTree).not.toBeUndefined()
if (!boardTree) {
fail('incrementalUpdate')
}
expect(boardTree.views).toEqual([view, view2, view3])
expect(boardTree.allCards).toEqual([card, card2])
expect(boardTree.cardTemplates).toEqual([cardTemplate, cardTemplate2])
// Group / filter with sort
originalBoardTree = boardTree
boardTree = boardTree.copyWithView(view.id)
expect(boardTree).not.toBe(originalBoardTree)
expect(boardTree.activeView).toEqual(view)
expect(boardTree.cards).toEqual([card, card2])
expect(boardTree.orderedCards()).toEqual([card, card2])
// Group / filter without sort
originalBoardTree = boardTree
boardTree = boardTree.copyWithView(view2.id)
expect(boardTree).not.toBe(originalBoardTree)
expect(boardTree.activeView).toEqual(view2)
expect(boardTree.cards).toEqual([card, card2])
// Incremental update: No change
const anotherBoard = TestBlockFactory.createBoard()
const card4 = TestBlockFactory.createCard(anotherBoard)
originalBoardTree = boardTree
boardTree = MutableBoardTree.incrementalUpdate(boardTree, [anotherBoard, card4])
expect(boardTree).toBe(originalBoardTree) // Expect same value on no change
expect(boardTree).not.toBeUndefined()
if (!boardTree) {
fail('incrementalUpdate')
}
// Copy
// const boardTree2 = boardTree.mutableCopy()
// expect(boardTree2.board).toEqual(boardTree.board)
// expect(boardTree2.views).toEqual(boardTree.views)
// expect(boardTree2.allCards).toEqual(boardTree.allCards)
// expect(boardTree2.cardTemplates).toEqual(boardTree.cardTemplates)
// expect(boardTree2.allBlocks).toEqual(boardTree.allBlocks)
// Search text
const searchText = 'search text'
expect(boardTree.getSearchText()).toBeUndefined()
boardTree = boardTree.copyWithSearchText(searchText)
expect(boardTree.getSearchText()).toBe(searchText)
})
test('BoardTree: defaults', async () => {
const board = TestBlockFactory.createBoard()
board.cardProperties = []
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board])))
const boardTree = await MutableBoardTree.sync(board.id, 'noView')
expect(boardTree).not.toBeUndefined()
if (!boardTree) {
fail('sync')
}
expect(FetchMock.fn).toBeCalledTimes(1)
expect(boardTree.board).not.toBeUndefined()
expect(boardTree.activeView).not.toBeUndefined()
expect(boardTree.views.length).toEqual(1)
expect(boardTree.allCards).toEqual([])
expect(boardTree.cardTemplates).toEqual([])
// Match everything except for cardProperties
board.cardProperties = boardTree.board!.cardProperties.slice()
expect(boardTree.board).toEqual(board)
})

View File

@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IBlock, IMutableBlock} from '../blocks/block'
import {IBlock} from '../blocks/block'
import {Board, IPropertyOption, IPropertyTemplate, MutableBoard} from '../blocks/board'
import {BoardView, MutableBoardView} from '../blocks/boardView'
import {Card, MutableCard} from '../blocks/card'
@ -21,9 +21,10 @@ interface BoardTree {
readonly cards: readonly Card[]
readonly cardTemplates: readonly Card[]
readonly allCards: readonly Card[]
readonly allBlocks: readonly IBlock[]
readonly visibleGroups: readonly Group[]
readonly hiddenGroups: readonly Group[]
readonly allBlocks: readonly IBlock[]
readonly activeView: BoardView
readonly groupByProperty?: IPropertyTemplate
@ -31,63 +32,80 @@ interface BoardTree {
getSearchText(): string | undefined
orderedCards(): Card[]
mutableCopy(): MutableBoardTree
copyWithView(viewId: string): BoardTree
copyWithSearchText(searchText?: string): BoardTree
}
class MutableBoardTree implements BoardTree {
board!: MutableBoard
board: MutableBoard
views: MutableBoardView[] = []
cards: MutableCard[] = []
cardTemplates: MutableCard[] = []
visibleGroups: Group[] = []
hiddenGroups: Group[] = []
activeView!: MutableBoardView
groupByProperty?: IPropertyTemplate
private rawBlocks: IBlock[] = []
private searchText?: string
allCards: MutableCard[] = []
get allBlocks(): IBlock[] {
return [this.board, ...this.views, ...this.allCards, ...this.cardTemplates]
}
constructor(private boardId: string) {
constructor(board: MutableBoard) {
this.board = board
}
async sync(): Promise<void> {
this.rawBlocks = await octoClient.getSubtree(this.boardId)
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
}
// Factory methods
incrementalUpdate(updatedBlocks: IBlock[]): boolean {
const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.id === this.boardId || block.parentId === this.boardId)
if (relevantBlocks.length < 1) {
return false
static async sync(boardId: string, viewId: string): Promise<BoardTree | undefined> {
const rawBlocks = await octoClient.getSubtree(boardId)
const newBoardTree = this.buildTree(boardId, rawBlocks)
if (newBoardTree) {
newBoardTree.setActiveView(viewId)
}
this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, relevantBlocks)
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
return true
return newBoardTree
}
private rebuild(blocks: IMutableBlock[]) {
this.board = blocks.find((block) => block.type === 'board') as MutableBoard
this.views = blocks.filter((block) => block.type === 'view').
sort((a, b) => a.title.localeCompare(b.title)) as MutableBoardView[]
this.allCards = blocks.filter((block) => block.type === 'card' && !(block as Card).isTemplate) as MutableCard[]
this.cardTemplates = blocks.filter((block) => block.type === 'card' && (block as Card).isTemplate).
sort((a, b) => a.title.localeCompare(b.title)) as MutableCard[]
this.cards = []
static incrementalUpdate(boardTree: BoardTree, updatedBlocks: IBlock[]): BoardTree | undefined {
const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.id === boardTree.board.id || block.parentId === boardTree.board.id)
if (relevantBlocks.length < 1) {
// No change
return boardTree
}
const rawBlocks = OctoUtils.mergeBlocks(boardTree.allBlocks, relevantBlocks)
const newBoardTree = this.buildTree(boardTree.board.id, rawBlocks)
if (newBoardTree && boardTree.activeView) {
newBoardTree.setActiveView(boardTree.activeView.id)
}
return newBoardTree
}
this.ensureMinimumSchema()
private static buildTree(boardId: string, sourceBlocks: readonly IBlock[]): MutableBoardTree | undefined {
const blocks = OctoUtils.hydrateBlocks(sourceBlocks)
const board = blocks.find((block) => block.type === 'board' && block.id === boardId) as MutableBoard
if (!board) {
return undefined
}
const boardTree = new MutableBoardTree(board)
boardTree.views = blocks.filter((block) => block.type === 'view').
sort((a, b) => a.title.localeCompare(b.title)) as MutableBoardView[]
boardTree.allCards = blocks.filter((block) => block.type === 'card' && !(block as Card).isTemplate) as MutableCard[]
boardTree.cardTemplates = blocks.filter((block) => block.type === 'card' && (block as Card).isTemplate).
sort((a, b) => a.title.localeCompare(b.title)) as MutableCard[]
boardTree.cards = []
boardTree.ensureMinimumSchema()
return boardTree
}
private ensureMinimumSchema(): boolean {
let didChange = false
// At least one select property
const selectProperties = this.board?.cardProperties.find((o) => o.type === 'select')
const selectProperties = this.board.cardProperties.find((o) => o.type === 'select')
if (!selectProperties) {
const newBoard = new MutableBoard(this.board)
newBoard.rootId = newBoard.id
@ -119,14 +137,20 @@ class MutableBoardTree implements BoardTree {
return didChange
}
setActiveView(viewId: string): void {
let view = this.views.find((o) => o.id === viewId)
if (!view) {
Utils.logError(`Cannot find BoardView: ${viewId}`)
private setActiveView(viewId?: string): void {
let view: MutableBoardView | undefined
if (viewId) {
view = this.views.find((o) => o.id === viewId)
if (!view) {
Utils.logError(`Cannot find BoardView: ${viewId}`)
view = this.views[0]
}
} else {
// Default to first view
view = this.views[0]
}
this.activeView = view
this.activeView = view!
// Fix missing group by (e.g. for new views)
if (this.activeView.viewType === 'board' && !this.activeView.groupById) {
@ -140,12 +164,16 @@ class MutableBoardTree implements BoardTree {
return this.searchText
}
setSearchText(text?: string): void {
private setSearchText(text?: string): void {
this.searchText = text
this.applyFilterSortAndGroup()
}
private applyFilterSortAndGroup(): void {
if (!this.activeView) {
Utils.assertFailure('activeView')
return
}
Utils.assert(this.allCards !== undefined)
this.cards = this.filterCards(this.allCards) as MutableCard[]
@ -400,9 +428,19 @@ class MutableBoardTree implements BoardTree {
return cards
}
mutableCopy(): MutableBoardTree {
const boardTree = new MutableBoardTree(this.boardId)
boardTree.incrementalUpdate(this.rawBlocks)
private mutableCopy(): MutableBoardTree {
return MutableBoardTree.buildTree(this.board.id, this.allBlocks)!
}
copyWithView(viewId: string): BoardTree {
const boardTree = this.mutableCopy()
boardTree.setActiveView(viewId)
return boardTree
}
copyWithSearchText(searchText?: string): BoardTree {
const boardTree = this.mutableCopy()
boardTree.setSearchText(searchText)
return boardTree
}
}

View File

@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Disable console log
console.log = jest.fn()
import 'isomorphic-fetch'
import {TestBlockFactory} from '../test/testBlockFactory'
import {FetchMock} from '../test/fetchMock'
import {Utils} from '../utils'
import {CardTree, MutableCardTree} from './cardTree'
global.fetch = FetchMock.fn
beforeEach(() => {
FetchMock.fn.mockReset()
})
test('CardTree', async () => {
const card = TestBlockFactory.createCard()
expect(card.id).not.toBeNull()
const comment = TestBlockFactory.createComment(card)
// Content
const text = TestBlockFactory.createText(card)
await Utils.sleep(10)
const image = TestBlockFactory.createImage(card)
await Utils.sleep(10)
const divider = TestBlockFactory.createDivider(card)
card.contentOrder = [image.id, divider.id, text.id]
let cardTree: CardTree | undefined
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([card, comment, text, image, divider])))
cardTree = await MutableCardTree.sync('invalid_id')
expect(cardTree).toBeUndefined()
expect(FetchMock.fn).toBeCalledTimes(1)
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([card, comment, text, image, divider])))
cardTree = await MutableCardTree.sync(card.id)
expect(cardTree).not.toBeUndefined()
if (!cardTree) {
fail('sync')
}
expect(FetchMock.fn).toBeCalledTimes(2)
expect(cardTree.card).toEqual(card)
expect(cardTree.comments).toEqual([comment])
expect(cardTree.contents).toEqual([image, divider, text]) // Must match specified card.contentOrder
// Incremental update
const comment2 = TestBlockFactory.createComment(card)
const text2 = TestBlockFactory.createText(card)
await Utils.sleep(10)
const image2 = TestBlockFactory.createImage(card)
await Utils.sleep(10)
const divider2 = TestBlockFactory.createDivider(card)
cardTree = MutableCardTree.incrementalUpdate(cardTree, [comment2, text2, image2, divider2])
expect(cardTree).not.toBeUndefined()
if (!cardTree) {
fail('incrementalUpdate')
}
expect(cardTree.comments).toEqual([comment, comment2])
// The added content's order was not specified in card.contentOrder, so much match created date order
expect(cardTree.contents).toEqual([image, divider, text, text2, image2, divider2])
// Incremental update: No change
const anotherCard = TestBlockFactory.createCard()
const comment3 = TestBlockFactory.createComment(anotherCard)
const originalCardTree = cardTree
cardTree = MutableCardTree.incrementalUpdate(cardTree, [comment3])
expect(cardTree).toBe(originalCardTree)
expect(cardTree).not.toBeUndefined()
if (!cardTree) {
fail('incrementalUpdate')
}
// Copy
// const cardTree2 = cardTree.mutableCopy()
// expect(cardTree2.card).toEqual(cardTree.card)
// expect(cardTree2.comments).toEqual(cardTree.comments)
// expect(cardTree2.contents).toEqual(cardTree.contents)
})

View File

@ -2,59 +2,68 @@
// See LICENSE.txt for license information.
import {IBlock} from '../blocks/block'
import {Card, MutableCard} from '../blocks/card'
import {IOrderedBlock} from '../blocks/orderedBlock'
import {IContentBlock} from '../blocks/contentBlock'
import octoClient from '../octoClient'
import {OctoUtils} from '../octoUtils'
interface CardTree {
readonly card: Card
readonly comments: readonly IBlock[]
readonly contents: readonly IOrderedBlock[]
mutableCopy(): MutableCardTree
readonly contents: readonly IContentBlock[]
readonly allBlocks: readonly IBlock[]
}
class MutableCardTree implements CardTree {
card!: MutableCard
card: MutableCard
comments: IBlock[] = []
contents: IOrderedBlock[] = []
contents: IContentBlock[] = []
private rawBlocks: IBlock[] = []
constructor(private cardId: string) {
get allBlocks(): IBlock[] {
return [this.card, ...this.comments, ...this.contents]
}
async sync(): Promise<void> {
this.rawBlocks = await octoClient.getSubtree(this.cardId)
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
constructor(card: MutableCard) {
this.card = card
}
incrementalUpdate(updatedBlocks: IBlock[]): boolean {
const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.id === this.cardId || block.parentId === this.cardId)
// Factory methods
static async sync(boardId: string): Promise<CardTree | undefined> {
const rawBlocks = await octoClient.getSubtree(boardId)
return this.buildTree(boardId, rawBlocks)
}
static incrementalUpdate(cardTree: CardTree, updatedBlocks: IBlock[]): CardTree | undefined {
const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.id === cardTree.card.id || block.parentId === cardTree.card.id)
if (relevantBlocks.length < 1) {
return false
// No change
return cardTree
}
this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, relevantBlocks)
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
return true
const rawBlocks = OctoUtils.mergeBlocks(cardTree.allBlocks, relevantBlocks)
return this.buildTree(cardTree.card.id, rawBlocks)
}
private rebuild(blocks: IBlock[]) {
this.card = blocks.find((o) => o.id === this.cardId) as MutableCard
private static buildTree(cardId: string, sourceBlocks: readonly IBlock[]): MutableCardTree | undefined {
const blocks = OctoUtils.hydrateBlocks(sourceBlocks)
this.comments = blocks.
const card = blocks.find((o) => o.type === 'card' && o.id === cardId) as MutableCard
if (!card) {
return undefined
}
const cardTree = new MutableCardTree(card)
cardTree.comments = blocks.
filter((block) => block.type === 'comment').
sort((a, b) => a.createAt - b.createAt)
const contentBlocks = blocks.filter((block) => block.type === 'text' || block.type === 'image' || block.type === 'divider') as IOrderedBlock[]
this.contents = contentBlocks.sort((a, b) => a.order - b.order)
}
const contentBlocks = blocks.filter((block) => block.type === 'text' || block.type === 'image' || block.type === 'divider') as IContentBlock[]
cardTree.contents = OctoUtils.getBlockOrder(card.contentOrder, contentBlocks)
mutableCopy(): MutableCardTree {
const cardTree = new MutableCardTree(this.cardId)
cardTree.incrementalUpdate(this.rawBlocks)
return cardTree
}
// private mutableCopy(): MutableCardTree {
// return MutableCardTree.buildTree(this.card.id, this.allBlocks)!
// }
}
export {MutableCardTree, CardTree}

View File

@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
console.log = jest.fn()
import 'isomorphic-fetch'
import {TestBlockFactory} from '../test/testBlockFactory'
import {FetchMock} from '../test/fetchMock'
import {MutableWorkspaceTree} from './workspaceTree'
global.fetch = FetchMock.fn
beforeEach(() => {
FetchMock.fn.mockReset()
})
test('WorkspaceTree', async () => {
const board = TestBlockFactory.createBoard()
const boardTemplate = TestBlockFactory.createBoard()
boardTemplate.isTemplate = true
const view = TestBlockFactory.createBoardView(board)
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, boardTemplate])))
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([view])))
let workspaceTree = await MutableWorkspaceTree.sync()
expect(workspaceTree).not.toBeUndefined()
if (!workspaceTree) {
fail('sync')
}
expect(FetchMock.fn).toBeCalledTimes(2)
expect(workspaceTree.boards).toEqual([board])
expect(workspaceTree.boardTemplates).toEqual([boardTemplate])
expect(workspaceTree.views).toEqual([view])
// Incremental update
const board2 = TestBlockFactory.createBoard()
const boardTemplate2 = TestBlockFactory.createBoard()
boardTemplate2.isTemplate = true
const view2 = TestBlockFactory.createBoardView(board2)
workspaceTree = MutableWorkspaceTree.incrementalUpdate(workspaceTree, [board2, boardTemplate2, view2])
expect(workspaceTree).not.toBeUndefined()
if (!workspaceTree) {
fail('incrementalUpdate')
}
expect(workspaceTree.boards).toEqual([board, board2])
expect(workspaceTree.boardTemplates).toEqual([boardTemplate, boardTemplate2])
expect(workspaceTree.views).toEqual([view, view2])
// Incremental update: No change
const card = TestBlockFactory.createCard()
const originalWorkspaceTree = workspaceTree
workspaceTree = MutableWorkspaceTree.incrementalUpdate(workspaceTree, [card])
expect(workspaceTree).toBe(originalWorkspaceTree)
expect(workspaceTree).not.toBeUndefined()
if (!workspaceTree) {
fail('incrementalUpdate')
}
expect(workspaceTree.boards).toEqual([board, board2])
expect(workspaceTree.boardTemplates).toEqual([boardTemplate, boardTemplate2])
expect(workspaceTree.views).toEqual([view, view2])
// Copy
// const workspaceTree2 = workspaceTree.mutableCopy()
// expect(workspaceTree2.boards).toEqual(workspaceTree.boards)
// expect(workspaceTree2.boardTemplates).toEqual(workspaceTree.boardTemplates)
// expect(workspaceTree2.views).toEqual(workspaceTree.views)
})

View File

@ -10,49 +10,54 @@ interface WorkspaceTree {
readonly boards: readonly Board[]
readonly boardTemplates: readonly Board[]
readonly views: readonly BoardView[]
mutableCopy(): MutableWorkspaceTree
readonly allBlocks: readonly IBlock[]
}
class MutableWorkspaceTree {
boards: Board[] = []
boardTemplates: Board[] = []
views: BoardView[] = []
get allBlocks(): IBlock[] {
return [...this.boards, ...this.boardTemplates, ...this.views]
}
private rawBlocks: IBlock[] = []
// Factory methods
async sync(): Promise<void> {
static async sync(): Promise<WorkspaceTree> {
const rawBoards = await octoClient.getBlocksWithType('board')
const rawViews = await octoClient.getBlocksWithType('view')
this.rawBlocks = [...rawBoards, ...rawViews]
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
const rawBlocks = [...rawBoards, ...rawViews]
return this.buildTree(rawBlocks)
}
incrementalUpdate(updatedBlocks: IBlock[]): boolean {
static incrementalUpdate(workspaceTree: WorkspaceTree, updatedBlocks: IBlock[]): WorkspaceTree {
const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.type === 'board' || block.type === 'view')
if (relevantBlocks.length < 1) {
return false
// No change
return workspaceTree
}
this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, updatedBlocks)
this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks))
return true
const rawBlocks = OctoUtils.mergeBlocks(workspaceTree.allBlocks, relevantBlocks)
return this.buildTree(rawBlocks)
}
private rebuild(blocks: IBlock[]) {
const allBoards = blocks.filter((block) => block.type === 'board') as Board[]
this.boards = allBoards.filter((block) => !block.isTemplate).
sort((a, b) => a.title.localeCompare(b.title)) as Board[]
this.boardTemplates = allBoards.filter((block) => block.isTemplate).
sort((a, b) => a.title.localeCompare(b.title)) as Board[]
this.views = blocks.filter((block) => block.type === 'view').
sort((a, b) => a.title.localeCompare(b.title)) as BoardView[]
}
private static buildTree(sourceBlocks: readonly IBlock[]): MutableWorkspaceTree {
const blocks = OctoUtils.hydrateBlocks(sourceBlocks)
mutableCopy(): MutableWorkspaceTree {
const workspaceTree = new MutableWorkspaceTree()
workspaceTree.incrementalUpdate(this.rawBlocks)
const allBoards = blocks.filter((block) => block.type === 'board') as Board[]
workspaceTree.boards = allBoards.filter((block) => !block.isTemplate).
sort((a, b) => a.title.localeCompare(b.title)) as Board[]
workspaceTree.boardTemplates = allBoards.filter((block) => block.isTemplate).
sort((a, b) => a.title.localeCompare(b.title)) as Board[]
workspaceTree.views = blocks.filter((block) => block.type === 'view').
sort((a, b) => a.title.localeCompare(b.title)) as BoardView[]
return workspaceTree
}
// private mutableCopy(): MutableWorkspaceTree {
// return MutableWorkspaceTree.buildTree(this.allBlocks)!
// }
}
export {MutableWorkspaceTree, WorkspaceTree}

View File

@ -10,6 +10,7 @@ type Props = {
placeholderText?: string
className?: string
saveOnEsc?: boolean
readonly?: boolean
onCancel?: () => void
onSave?: (saveType: 'onEnter'|'onEsc'|'onBlur') => void
@ -65,6 +66,7 @@ export default class Editable extends React.Component<Props> {
this.blur()
}
}}
readOnly={this.props.readonly}
/>
)
}

View File

@ -0,0 +1,6 @@
@use './standardIcon.scss';
.BoardIcon {
@extend .StandardIcon;
stroke-width: 8px;
}

View File

@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import './board.scss'
export default function BoardIcon(): JSX.Element {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
className='TableIcon Icon'
viewBox='0 0 100 100'
>
<rect
x='10'
y='10'
width='80'
height='80'
rx='5'
ry='5'
/>
<polyline
points='28,25 28,55'
style={{strokeWidth: '15px'}}
/>
<polyline
points='50,25 50,70'
style={{strokeWidth: '15px'}}
/>
<polyline
points='72,25 72,45'
style={{strokeWidth: '15px'}}
/>
</svg>
)
}

View File

@ -0,0 +1,6 @@
@use './standardIcon.scss';
.CardIcon {
@extend .StandardIcon;
stroke-width: 6px;
}

View File

@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import './card.scss'
export default function CardIcon(): JSX.Element {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
className='CardIcon Icon'
viewBox='0 0 100 100'
>
<rect
x='20'
y='30'
width='60'
height='40'
rx='3'
ry='3'
/>
</svg>
)
}

View File

@ -1,5 +1,5 @@
.DeleteIcon {
fill: rgba(var(--main-fg), 0.7);
fill: rgba(var(--main-fg), 0.5);
stroke: none;
width: 24px;
height: 24px;

View File

@ -0,0 +1,7 @@
.StandardIcon {
stroke: rgba(var(--main-fg), 0.5);
stroke-width: 4px;
fill: none;
width: 24px;
height: 24px;
}

View File

@ -0,0 +1,6 @@
@use './standardIcon.scss';
.TableIcon {
@extend .StandardIcon;
stroke-width: 8px;
}

View File

@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import './table.scss'
export default function TableIcon(): JSX.Element {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
className='TableIcon Icon'
viewBox='0 0 100 100'
>
<rect
x='10'
y='10'
width='80'
height='80'
rx='5'
ry='5'
/>
<polyline points='37,10 37,90'/>
<polyline points='10,37 90,37'/>
<polyline points='10,63 90,63'/>
</svg>
)
}

View File

@ -60,6 +60,7 @@
>.Icon {
width: 16px;
height: 16px;
line-height: 16px;
}
>.IconButton .Icon {
margin-right: 0;
@ -72,6 +73,15 @@
width: 20px;
flex: 0 0 auto;
}
@media not screen and (max-width: 430px) {
&.top {
bottom: 100%;
}
&.left {
right: 0;
}
}
}
.Menu, .SubMenuOption .SubMenu {
@ -122,13 +132,6 @@
}
}
@media not screen and (max-width: 430px) {
&.top {
bottom: 100%;
}
&.left {
right: 0;
}
.hideOnWidescreen {
/* Hide controls (e.g. close button) on larger screens */
display: none !important;

View File

@ -1,4 +1,8 @@
.MenuWrapper {
position: relative;
cursor: pointer;
&.disabled {
cursor: default;
}
}

View File

@ -11,6 +11,7 @@ type Props = {
isDisabled?: boolean;
stopPropagationOnToggle?: boolean;
className?: string
disabled?: boolean
}
type State = {
@ -68,6 +69,10 @@ export default class MenuWrapper extends React.PureComponent<Props, State> {
}
private toggle = (e: React.MouseEvent<HTMLDivElement, MouseEvent>): void => {
if (this.props.disabled) {
return
}
/**
* This is only here so that we can toggle the menus in the sidebar, because the default behavior of the mobile
* version (ie the one that uses a modal) needs propagation to close the modal after selecting something
@ -84,15 +89,22 @@ export default class MenuWrapper extends React.PureComponent<Props, State> {
public render(): JSX.Element {
const {children} = this.props
let className = 'MenuWrapper'
if (this.props.disabled) {
className += ' disabled'
}
if (this.props.className) {
className += ' ' + this.props.className
}
return (
<div
className={`MenuWrapper ${this.props.className || ''}`}
className={className}
onClick={this.toggle}
ref={this.node}
>
{children ? Object.values(children)[0] : null}
{children && this.state.open ? Object.values(children)[1] : null}
{children && !this.props.disabled && this.state.open ? Object.values(children)[1] : null}
</div>
)
}

View File

@ -1,12 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {PropertyType} from '../blocks/board'
import {Utils} from '../utils'
import Menu from '../widgets/menu'
import './propertyMenu.scss'
type Props = {
@ -16,13 +15,14 @@ type Props = {
onNameChanged: (newName: string) => void
onTypeChanged: (newType: PropertyType) => void
onDelete: (id: string) => void
intl: IntlShape
}
type State = {
name: string
}
export default class PropertyMenu extends React.PureComponent<Props, State> {
class PropertyMenu extends React.PureComponent<Props, State> {
private nameTextbox = React.createRef<HTMLInputElement>()
constructor(props: Props) {
@ -36,21 +36,23 @@ export default class PropertyMenu extends React.PureComponent<Props, State> {
}
private typeDisplayName(type: PropertyType): string {
const {intl} = this.props
switch (type) {
case 'text': return 'Text'
case 'number': return 'Number'
case 'select': return 'Select'
case 'multiSelect': return 'Multi Select'
case 'person': return 'Person'
case 'file': return 'File or Media'
case 'checkbox': return 'Checkbox'
case 'url': return 'URL'
case 'email': return 'Email'
case 'phone': return 'Phone'
case 'createdTime': return 'Created Time'
case 'createdBy': return 'Created By'
case 'updatedTime': return 'Updated Time'
case 'updatedBy': return 'Updated By'
case 'text': return intl.formatMessage({id: 'PropertyType.Text', defaultMessage: 'Text'})
case 'number': return intl.formatMessage({id: 'PropertyType.Number', defaultMessage: 'Number'})
case 'select': return intl.formatMessage({id: 'PropertyType.Select', defaultMessage: 'Select'})
case 'multiSelect': return intl.formatMessage({id: 'PropertyType.MultiSelect', defaultMessage: 'Multi Select'})
case 'person': return intl.formatMessage({id: 'PropertyType.Person', defaultMessage: 'Person'})
case 'file': return intl.formatMessage({id: 'PropertyType.File', defaultMessage: 'File or Media'})
case 'checkbox': return intl.formatMessage({id: 'PropertyType.Checkbox', defaultMessage: 'Checkbox'})
case 'url': return intl.formatMessage({id: 'PropertyType.URL', defaultMessage: 'URL'})
case 'email': return intl.formatMessage({id: 'PropertyType.Email', defaultMessage: 'Email'})
case 'phone': return intl.formatMessage({id: 'PropertyType.Phone', defaultMessage: 'Phone'})
case 'createdTime': return intl.formatMessage({id: 'PropertyType.CreatedTime', defaultMessage: 'Created Time'})
case 'createdBy': return intl.formatMessage({id: 'PropertyType.CreatedBy', defaultMessage: 'Created By'})
case 'updatedTime': return intl.formatMessage({id: 'PropertyType.UpdatedTime', defaultMessage: 'Updated Time'})
case 'updatedBy': return intl.formatMessage({id: 'PropertyType.UpdatedBy', defaultMessage: 'Updated By'})
default: {
Utils.assertFailure(`typeDisplayName, unhandled type: ${type}`)
return type
@ -58,6 +60,10 @@ export default class PropertyMenu extends React.PureComponent<Props, State> {
}
}
private typeMenuTitle(type: PropertyType): string {
return `${this.props.intl.formatMessage({id: 'PropertyMenu.typeTitle', defaultMessage: 'Type'})}: ${this.typeDisplayName(type)}`
}
public render(): JSX.Element {
return (
<Menu>
@ -78,8 +84,16 @@ export default class PropertyMenu extends React.PureComponent<Props, State> {
/>
<Menu.SubMenu
id='type'
name={this.typeDisplayName(this.props.propertyType)}
name={this.typeMenuTitle(this.props.propertyType)}
>
<Menu.Label>
<b>
{this.props.intl.formatMessage({id: 'PropertyMenu.changeType', defaultMessage: 'Change property type'})}
</b>
</Menu.Label>
<Menu.Separator/>
<Menu.Text
id='text'
name='Text'
@ -115,3 +129,5 @@ export default class PropertyMenu extends React.PureComponent<Props, State> {
)
}
}
export default injectIntl(PropertyMenu)

View File

@ -13,14 +13,7 @@
"resolveJsonModule": true,
"incremental": false,
"outDir": "./dist",
"baseUrl": ".",
"moduleResolution": "node",
"paths": {
"*": [
"node_modules/*",
"@custom_types/*"
]
}
"moduleResolution": "node"
},
"include": [
"."

26
website/.editorconfig Normal file
View File

@ -0,0 +1,26 @@
# http://editorconfig.org/
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.{md,css,html}]
indent_style = space
indent_size = 4
[*.toml]
indent_style = space
indent_size = 2
[*.go]
indent_style = tab
[Makefile,*.mk]
indent_style = tab
[*.md]
trim_trailing_whitespace = false

9
website/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# build artifacts
dist
# os artifacts
*.swp
.DS_Store
# IDE artifacts
.idea/

11
website/Makefile Normal file
View File

@ -0,0 +1,11 @@
BASE_URL?=http://www.mattergoals.com
.PHONY: dist
dist:
rm -rf ./dist
hugo -s site --destination ../dist/html -b$(BASE_URL)
.PHONY: run
run:
hugo server --buildDrafts --disableFastRender -F -s site

21
website/README.md Normal file
View File

@ -0,0 +1,21 @@
# Mattergoals website
Website for Mattergoals, built using [Hugo](https://gohugo.io/).
## How to build
1. Follow [Hugo documentation](https://gohugo.io/getting-started/installing/) to install Hugo
```bash
# Eg. for Mac OS X
brew install hugo
```
2. Start the development server
```bash
make run
```
3. Go to http://localhost:1313 to see the running server

View File

@ -0,0 +1,5 @@
---
title: "{{ replace .TranslationBaseName "-" " " | title }}"
date: {{ .Date }}
draft: true
---

137
website/site/config.toml Normal file
View File

@ -0,0 +1,137 @@
# Page settings
baseURL = "https://tasks.octo.mattermost.com/"
canonifyURLs = true
#relativeURLs = true
title = "Mattergoals"
languageCode = "en-us"
publishDir = "../docs"
pygmentsCodeFences = true
pygmentsStyle = "manni"
[taxonomies]
category = "categories"
[params]
# Meta
author = ""
description = ""
email = ""
ghrepo = "https://github.com/mattermost/mattermost-octo-tasks/tree/main/website"
[params.mailinglist]
enable = false
[params.notification]
enable = false
url = "https://mattermost.com/careers"
text = "We're hiring!"
[params.search]
enable = false
[[params.sidebar.item]]
name = "download"
displayName = "Download"
draft = false
[[params.sidebar.item]]
name = "guide"
displayName = "User's Guide"
draft = false
[params.sidebar]
[[params.sidebar.item]]
name = "contribute"
displayName = "Contribute"
draft = false
# Navigation
[params.navigation]
brand = "Mattergoals"
home = "Home"
# You can add custom links before or after the default links
# Assign a weight to define the order
# prepended links
#[[menu.prepend]]
# url = "http://gohugo.io"
# name = "Hugo"
# weight = 10
# postpended links
[[menu.postpend]]
url = "/download/personal-edition"
name = "Download"
weight = 1
[[menu.postpend]]
url = "/guide/user"
name = "User's Guide"
weight = 2
[[menu.postpend]]
url = "/contribute/getting-started"
name = "Contribute"
weight = 3
[[menu.postpend]]
url = "/blog"
name = "Blog"
weight = 4
# Workaround to add draft status to menu items
[[params.navigation.drafts]]
Contribute = false
Integrate = false
Extend = false
Blog = false
Internal = false
'Admin Docs' = false
# Hero section
[params.hero]
title = "Get Mattergoals"
subtitle = ''
# Intro section
# Available icons: http://simplelineicons.com/
[params.intro]
[[params.intro.item]]
title = "Download"
description = "Download Mattergoals here."
url = "download/personal-edition"
button = "Download Now"
icon = "/img/download-icon.svg"
draft = false
[[params.intro.item]]
title = "Read Guide"
description = "Read the User's Guide to ge the most out of Mattergoals."
url = "guide/user"
button = "User's Guide"
icon = "/img/use-icon.svg"
draft = false
[[params.intro.item]]
title = "Contribute"
description = "Help build the future of productivity and submit code directly to the Mattergoals open-source project."
url = "contribute/getting-started"
button = "Start Contributing"
icon = "/img/contribute-icon.svg"
draft = false
# Footer section
[params.footer]
enable = true
twitter = 'https://twitter.com/mattermost'
facebook = 'https://www.facebook.com/Mattermost-2300985916642531/'
youtube = 'https://www.youtube.com/channel/UCNR05H72hi692y01bWaFXNA'
copyright = '&copy; Mattermost, Inc. All Rights Reserved.'
# Allows html in Hugo >= v0.60.0. See Github issue #506.
[markup]
[markup.goldmark]
[markup.goldmark.renderer]
unsafe = true

Some files were not shown because too many files have changed in this diff Show More