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:
commit
daae244cba
36
.github/workflows/build-mac.yml
vendored
Normal file
36
.github/workflows/build-mac.yml
vendored
Normal 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
41
.github/workflows/build-ubuntu.yml
vendored
Normal 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
32
.github/workflows/build-win.yml
vendored
Normal 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
48
.github/workflows/ci.yml
vendored
Normal 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
3
.gitignore
vendored
@ -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
15
.vscode/launch.json
vendored
@ -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}",
|
||||
|
38
Makefile
38
Makefile
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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)",
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
233
server/services/store/sqlstore/initializations/bindata.go
Normal file
233
server/services/store/sqlstore/initializations/bindata.go
Normal file
File diff suppressed because one or more lines are too long
@ -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
63
server/services/store/sqlstore/initialize.go
Normal file
63
server/services/store/sqlstore/initialize.go
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
5
webapp/cypress.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"chromeWebSecurity": false,
|
||||
"baseUrl": "http://localhost:8088",
|
||||
"video": false
|
||||
}
|
11
webapp/cypress/config.json
Normal file
11
webapp/cypress/config.json
Normal 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": []
|
||||
}
|
118
webapp/cypress/integration/createBoard.js
Normal file
118
webapp/cypress/integration/createBoard.js
Normal 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');
|
||||
});
|
||||
});
|
9
webapp/cypress/integration/homepage.js
Normal file
9
webapp/cypress/integration/homepage.js
Normal 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');
|
||||
});
|
||||
});
|
21
webapp/cypress/plugins/index.js
Normal file
21
webapp/cypress/plugins/index.js
Normal 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
|
||||
}
|
25
webapp/cypress/support/commands.js
Normal file
25
webapp/cypress/support/commands.js
Normal 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) => { ... })
|
20
webapp/cypress/support/index.js
Normal file
20
webapp/cypress/support/index.js
Normal 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')
|
10
webapp/cypress/tsconfig.json
Normal file
10
webapp/cypress/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["cypress"]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
@ -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"
|
||||
}
|
@ -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
1515
webapp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
85
webapp/src/blocks/block.test.ts
Normal file
85
webapp/src/blocks/block.test.ts
Normal 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)
|
||||
})
|
@ -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}
|
||||
|
@ -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 {
|
||||
|
10
webapp/src/blocks/contentBlock.ts
Normal file
10
webapp/src/blocks/contentBlock.ts
Normal 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}
|
@ -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'
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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}
|
@ -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'
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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 }
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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()
|
||||
|
@ -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>
|
||||
|
@ -1,6 +1,7 @@
|
||||
.HorizontalGrip {
|
||||
width: 5px;
|
||||
cursor: ew-resize;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(90, 192, 255, 0.7);
|
||||
|
@ -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()
|
||||
}
|
||||
}}
|
||||
|
113
webapp/src/components/newCardButton.tsx
Normal file
113
webapp/src/components/newCardButton.tsx
Normal 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)
|
@ -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'
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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],
|
||||
|
@ -55,6 +55,8 @@
|
||||
|
||||
.octo-propertyvalue {
|
||||
line-height: 17px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.octo-editable,
|
||||
|
@ -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) {
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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}
|
||||
/>)
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
82
webapp/src/octoClient.test.ts
Normal file
82
webapp/src/octoClient.test.ts
Normal 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
|
||||
}
|
@ -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)!
|
||||
|
@ -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})
|
||||
}
|
||||
}
|
||||
|
16
webapp/src/test/fetchMock.ts
Normal file
16
webapp/src/test/fetchMock.ts
Normal 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}
|
125
webapp/src/test/testBlockFactory.ts
Normal file
125
webapp/src/test/testBlockFactory.ts
Normal 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}
|
@ -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
15
webapp/src/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": [
|
||||
"../node_modules/*",
|
||||
"../@custom_types/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts"
|
||||
]
|
||||
}
|
@ -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)
|
||||
|
||||
|
@ -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}
|
||||
|
146
webapp/src/viewModel/boardTree.test.ts
Normal file
146
webapp/src/viewModel/boardTree.test.ts
Normal 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)
|
||||
})
|
@ -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
|
||||
}
|
||||
}
|
||||
|
87
webapp/src/viewModel/cardTree.test.ts
Normal file
87
webapp/src/viewModel/cardTree.test.ts
Normal 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)
|
||||
})
|
@ -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}
|
||||
|
70
webapp/src/viewModel/workspaceTree.test.ts
Normal file
70
webapp/src/viewModel/workspaceTree.test.ts
Normal 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)
|
||||
})
|
@ -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}
|
||||
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
6
webapp/src/widgets/icons/board.scss
Normal file
6
webapp/src/widgets/icons/board.scss
Normal file
@ -0,0 +1,6 @@
|
||||
@use './standardIcon.scss';
|
||||
|
||||
.BoardIcon {
|
||||
@extend .StandardIcon;
|
||||
stroke-width: 8px;
|
||||
}
|
37
webapp/src/widgets/icons/board.tsx
Normal file
37
webapp/src/widgets/icons/board.tsx
Normal 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>
|
||||
)
|
||||
}
|
6
webapp/src/widgets/icons/card.scss
Normal file
6
webapp/src/widgets/icons/card.scss
Normal file
@ -0,0 +1,6 @@
|
||||
@use './standardIcon.scss';
|
||||
|
||||
.CardIcon {
|
||||
@extend .StandardIcon;
|
||||
stroke-width: 6px;
|
||||
}
|
25
webapp/src/widgets/icons/card.tsx
Normal file
25
webapp/src/widgets/icons/card.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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;
|
||||
|
7
webapp/src/widgets/icons/standardIcon.scss
Normal file
7
webapp/src/widgets/icons/standardIcon.scss
Normal file
@ -0,0 +1,7 @@
|
||||
.StandardIcon {
|
||||
stroke: rgba(var(--main-fg), 0.5);
|
||||
stroke-width: 4px;
|
||||
fill: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
6
webapp/src/widgets/icons/table.scss
Normal file
6
webapp/src/widgets/icons/table.scss
Normal file
@ -0,0 +1,6 @@
|
||||
@use './standardIcon.scss';
|
||||
|
||||
.TableIcon {
|
||||
@extend .StandardIcon;
|
||||
stroke-width: 8px;
|
||||
}
|
28
webapp/src/widgets/icons/table.tsx
Normal file
28
webapp/src/widgets/icons/table.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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;
|
||||
|
@ -1,4 +1,8 @@
|
||||
.MenuWrapper {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
26
website/.editorconfig
Normal 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
9
website/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
# build artifacts
|
||||
dist
|
||||
|
||||
# os artifacts
|
||||
*.swp
|
||||
.DS_Store
|
||||
|
||||
# IDE artifacts
|
||||
.idea/
|
11
website/Makefile
Normal file
11
website/Makefile
Normal 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
21
website/README.md
Normal 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
|
5
website/site/archetypes/default.md
Normal file
5
website/site/archetypes/default.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
title: "{{ replace .TranslationBaseName "-" " " | title }}"
|
||||
date: {{ .Date }}
|
||||
draft: true
|
||||
---
|
137
website/site/config.toml
Normal file
137
website/site/config.toml
Normal 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 = '© 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
Loading…
Reference in New Issue
Block a user