1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-07-15 23:54:29 +02:00

Migrate webapp global state to redux (#737)

* Migrating workspace tree to redux

* More changes for use the redux store for boads and views

* Taking into account the templates on websocket event updates

* Fixing bug on boardTree maintenance

* Websocket client now connects once and subscribe/desubscribe on the fly

* Including usage of the new websocket client

* More work around migrating to redux

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* Fixing some things

* WIP

* WIP

* Another small fix

* Restoring filtering, sorting and grouping

* Fixing some other bugs

* Add search text reducer

* Fixing another drag and drop problem

* Improve store names and api

* Fixing small bgus

* Some small fixes

* fixing login

* Fixing register page

* Some other improvements

* Removing unneeded old files

* Removing the need of userCache

* Fixing comments and fixing content ordering

* Fixing sort

* Fixing some TODOs

* Fixing tests

* Fixing snapshot

* Fixing cypress tests

* Fix eslint

* Fixing server tests

* Updating the add cards actions

* Fixing some tiny navigation problems

* Mocking the api calls to pass the tests

* Migrating a new test to redux

* Adding the card right after the insert of the block (not wait for ws event)

* Showing the ws disconnect banner only after 5 seconds of disconnection

* Fixing share view

* Fix eslint

* Fixing problem with sort/groupby modifications

* Fixing some details on redirections and templates creation

* Fixing small bugs around undo

* Fix update properties on click outside the dialog

* Improving the column resize look and feel

* Removing the class based objects from the store (now they are all plain objects

* Fix eslint

* Fixing tests

* Removing unneeded code
This commit is contained in:
Jesús Espino
2021-08-02 17:46:00 +02:00
committed by GitHub
parent 615d7260f4
commit be28b7dad5
131 changed files with 3161 additions and 3833 deletions

View File

@ -226,6 +226,7 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
parentID := query.Get("parent_id")
blockType := query.Get("type")
all := query.Get("all")
container, err := a.getContainer(r)
if err != nil {
a.noContainerErrorResponse(w, r.URL.Path, err)
@ -236,12 +237,22 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
defer a.audit.LogRecord(audit.LevelRead, auditRec)
auditRec.AddMeta("parentID", parentID)
auditRec.AddMeta("blockType", blockType)
auditRec.AddMeta("all", all)
blocks, err := a.app.GetBlocks(*container, parentID, blockType)
var blocks []model.Block
if all != "" {
blocks, err = a.app.GetAllBlocks(*container)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
} else {
blocks, err = a.app.GetBlocks(*container, parentID, blockType)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
}
a.logger.Debug("GetBlocks",
mlog.String("parentID", parentID),

View File

@ -6,11 +6,11 @@ import (
)
func (a *App) GetBlocks(c store.Container, parentID string, blockType string) ([]model.Block, error) {
if len(blockType) > 0 && len(parentID) > 0 {
if blockType != "" && parentID != "" {
return a.store.GetBlocksWithParentAndType(c, parentID, blockType)
}
if len(blockType) > 0 {
if blockType != "" {
return a.store.GetBlocksWithType(c, blockType)
}

297
webapp/package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.8.1",
"dependencies": {
"@reduxjs/toolkit": "^1.6.0",
"easymde": "^2.15.0",
"emoji-mart": "^3.0.1",
"imagemin-gifsicle": "^7.0.0",
"imagemin-mozjpeg": "^9.0.0",
@ -32,7 +33,7 @@
"react-redux": "^7.2.4",
"react-router-dom": "^5.2.0",
"react-select": "^4.3.0",
"react-simplemde-editor": "^4.1.3"
"react-simplemde-editor": "^5.0.1"
},
"devDependencies": {
"@formatjs/cli": "^3.2.0",
@ -1480,18 +1481,6 @@
"redux": "^4.1.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0",
"react-redux": "^7.2.1"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@samverschueren/stream-to-observable": {
@ -1630,9 +1619,6 @@
"engines": {
"node": ">=10",
"npm": ">=6"
},
"peerDependencies": {
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@types/aria-query": {
@ -1683,9 +1669,9 @@
}
},
"node_modules/@types/codemirror": {
"version": "0.0.88",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.88.tgz",
"integrity": "sha512-FI9BvlO+SIEmKoIdrS9uphasiHJ/JbeUsAbVTdklBOcmnr/bQpJ6QaCw540FY98LGdDNl1Nyn7erGxo0eD2gOg==",
"version": "0.0.109",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.109.tgz",
"integrity": "sha512-cSdiHeeLjvGn649lRTNeYrVCDOgDrtP+bDDSFDd1TF+i0jKGPDRozno2NOJ9lTniso+taiv4kiVS8dgM8Jm5lg==",
"dependencies": {
"@types/tern": "*"
}
@ -1832,10 +1818,9 @@
}
},
"node_modules/@types/marked": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-2.0.0.tgz",
"integrity": "sha512-kSOVa3R6HJvFdd3UIbTYvrSBTPHjXhNErh7/8oSCKOwqdOkk4Oj8N77n+f6dsgd1jW3j3SU5EhnmRxPhNKOmtQ==",
"dev": true
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-2.0.4.tgz",
"integrity": "sha512-L9VRSe0Id8xbPL99mUo/4aKgD7ZoRwFZqUQScNKHi2pFjF9ZYSMNShUHD6VlMT6J/prQq0T1mxuU25m3R7dFzg=="
},
"node_modules/@types/minimatch": {
"version": "3.0.4",
@ -1991,9 +1976,9 @@
"dev": true
},
"node_modules/@types/tern": {
"version": "0.23.3",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.3.tgz",
"integrity": "sha512-imDtS4TAoTcXk0g7u4kkWqedB3E4qpjXzCpD2LU5M5NAXHzCDsypyvXSaG7mM8DKYkCRa7tFp4tS/lp/Wo7Q3w==",
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.4.tgz",
"integrity": "sha512-JAUw1iXGO1qaWwEOzxTKJZ/5JxVeON9kvGZ/osgZaJImBnyjyn0cjovPsf6FNLmyGY8Vw9DoXZCMlfMkMwHRWg==",
"dependencies": {
"@types/estree": "*"
}
@ -4145,9 +4130,9 @@
}
},
"node_modules/codemirror": {
"version": "5.59.4",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.59.4.tgz",
"integrity": "sha512-achw5JBgx8QPcACDDn+EUUXmCYzx/zxEtOGXyjvLEvYY8GleUrnfm5D+Zb+UjShHggXKDT9AXrbkBZX6a0YSQg=="
"version": "5.62.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.62.2.tgz",
"integrity": "sha512-tVFMUa4J3Q8JUd1KL9yQzQB0/BJt7ZYZujZmTPgo/54Lpuq3ez4C8x/ATUY/wv7b7X3AUq8o3Xd+2C5ZrCGWHw=="
},
"node_modules/codemirror-spell-checker": {
"version": "1.1.2",
@ -4374,12 +4359,7 @@
"version": "3.12.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.12.1.tgz",
"integrity": "sha512-Ne9DKPHTObRuB09Dru5AjwKjY4cJHVGu+y5f7coGn1E9Grkc3p2iBwE9AI/nJzsE29mQF7oq+mhYYRqOMFN1Bw==",
"dev": true,
"hasInstallScript": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
"dev": true
},
"node_modules/core-js-pure": {
"version": "3.9.1",
@ -4755,7 +4735,6 @@
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/cwebp-bin/-/cwebp-bin-5.1.0.tgz",
"integrity": "sha512-BsPKStaNr98zfxwejWWLIGELbPERULJoD2v5ijvpeutSAGsegX7gmABgnkRK7MUucCPROXXfaPqkLAwI509JzA==",
"hasInstallScript": true,
"dependencies": {
"bin-build": "^3.0.0",
"bin-wrapper": "^4.0.1",
@ -5521,13 +5500,26 @@
"integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI="
},
"node_modules/easymde": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.14.0.tgz",
"integrity": "sha512-yQh3EF1amknaxDhXE1L28kwknREU8S19o01ki0t6Q8ThECCipXTOM3E/LL32Ia5D3AsCBRbC1/fT5tpLniVGuw==",
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.15.0.tgz",
"integrity": "sha512-9jMRIVvKt1d0UjRN45yotUYECAM4xvw0TTAQw8sYDONP++keWJVnd8Xrn+V+vQEN/v9/X0SWEoo1rFSgCooGpw==",
"dependencies": {
"codemirror": "^5.59.2",
"@types/codemirror": "0.0.109",
"@types/marked": "^2.0.2",
"codemirror": "^5.61.0",
"codemirror-spell-checker": "1.1.2",
"marked": "^2.0.0"
"marked": "^2.0.3"
}
},
"node_modules/easymde/node_modules/marked": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-2.1.3.tgz",
"integrity": "sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA==",
"bin": {
"marked": "bin/marked"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/ecc-jsbn": {
@ -7051,10 +7043,6 @@
"integrity": "sha512-4pXwmBplsCPv8FOY1WRakF970TjNGnGnfbOnLqjlYvMiF1SR3yOHyxMR/YCXpPTOspNF5gwudqktIP4VsWkvBg==",
"bin": {
"xml2js": "cli.js"
},
"funding": {
"type": "paypal",
"url": "https://paypal.me/naturalintelligence"
}
},
"node_modules/fastest-levenshtein": {
@ -7108,18 +7096,6 @@
},
"engines": {
"node": ">=4.0.0"
},
"funding": {
"type": "charity",
"url": "https://www.justgiving.com/refugee-support-europe"
},
"peerDependencies": {
"node-fetch": "*"
},
"peerDependenciesMeta": {
"node-fetch": {
"optional": true
}
}
},
"node_modules/fetch-mock-jest": {
@ -7132,18 +7108,6 @@
},
"engines": {
"node": ">=8.0.0"
},
"funding": {
"type": "charity",
"url": "https://www.justgiving.com/refugee-support-europe"
},
"peerDependencies": {
"node-fetch": "*"
},
"peerDependenciesMeta": {
"node-fetch": {
"optional": true
}
}
},
"node_modules/fetch-mock/node_modules/path-to-regexp": {
@ -7544,7 +7508,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/gifsicle/-/gifsicle-5.2.0.tgz",
"integrity": "sha512-vOIS3j0XoTCxq9pkGj43gEix82RkI5FveNgaFZutjbaui/HH+4fR8Y56dwXDuxYo8hR4xOo6/j2h1WHoQW6XLw==",
"hasInstallScript": true,
"dependencies": {
"bin-build": "^3.0.0",
"bin-wrapper": "^4.0.0",
@ -7556,9 +7519,6 @@
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/imagemin/gisicle-bin?sponsor=1"
}
},
"node_modules/gifsicle/node_modules/cross-spawn": {
@ -7591,9 +7551,6 @@
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/gifsicle/node_modules/get-stream": {
@ -7602,9 +7559,6 @@
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gifsicle/node_modules/human-signals": {
@ -8237,17 +8191,15 @@
"dev": true,
"dependencies": {
"imagemin": "^7.0.1",
"loader-utils": "^2.0.0",
"object-assign": "^4.1.1",
"schema-utils": "^2.7.1"
},
"optionalDependencies": {
"imagemin-gifsicle": "^7.0.0",
"imagemin-mozjpeg": "^9.0.0",
"imagemin-optipng": "^8.0.0",
"imagemin-pngquant": "^9.0.1",
"imagemin-svgo": "^8.0.0",
"imagemin-webp": "^6.0.0"
"imagemin-webp": "^6.0.0",
"loader-utils": "^2.0.0",
"object-assign": "^4.1.1",
"schema-utils": "^2.7.1"
}
},
"node_modules/image-webpack-loader/node_modules/loader-utils": {
@ -8276,10 +8228,6 @@
},
"engines": {
"node": ">= 8.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/imagemin": {
@ -8311,9 +8259,6 @@
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/imagemin/imagemin-gifsicle?sponsor=1"
}
},
"node_modules/imagemin-mozjpeg": {
@ -8359,9 +8304,6 @@
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/imagemin-mozjpeg/node_modules/get-stream": {
@ -8373,9 +8315,6 @@
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/imagemin-mozjpeg/node_modules/is-stream": {
@ -8496,9 +8435,6 @@
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/imagemin-pngquant/node_modules/get-stream": {
@ -8510,9 +8446,6 @@
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/imagemin-pngquant/node_modules/is-stream": {
@ -8585,9 +8518,6 @@
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/imagemin-svgo?sponsor=1"
}
},
"node_modules/imagemin-webp": {
@ -8625,11 +8555,7 @@
"node_modules/immer": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.3.tgz",
"integrity": "sha512-mONgeNSMuyjIe0lkQPa9YhdmTv8P19IeHV0biYhcXhbd5dhdB9HSK93zBpyKjp6wersSUgT5QyU0skmejUVP2A==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
"integrity": "sha512-mONgeNSMuyjIe0lkQPa9YhdmTv8P19IeHV0biYhcXhbd5dhdB9HSK93zBpyKjp6wersSUgT5QyU0skmejUVP2A=="
},
"node_modules/import-fresh": {
"version": "3.3.0",
@ -8993,9 +8919,6 @@
"integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==",
"engines": {
"node": ">=0.10.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-fullwidth-code-point": {
@ -9101,10 +9024,7 @@
"node_modules/is-object": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA=="
},
"node_modules/is-observable": {
"version": "1.1.0",
@ -9218,9 +9138,6 @@
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-symbol": {
@ -11392,7 +11309,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/mozjpeg/-/mozjpeg-7.0.0.tgz",
"integrity": "sha512-mH7atSbIusVTO3A4H43sEdmveN3aWn54k6V0edefzCEvOsTrbjg5murY2TsNznaztWnIgaRbWxeLVp4IgKdedQ==",
"hasInstallScript": true,
"dependencies": {
"bin-build": "^3.0.0",
"bin-wrapper": "^4.0.0",
@ -11849,9 +11765,6 @@
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object.pick": {
@ -11920,7 +11833,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/optipng-bin/-/optipng-bin-7.0.0.tgz",
"integrity": "sha512-mesUAwfedu5p9gRQwlYgD6Svw5IH3VUIWDJj/9cNpP3yFNbbEVqkTMWYhrIEn/cxmbGA3LpZrdoV2Yl8OfmnIA==",
"hasInstallScript": true,
"dependencies": {
"bin-build": "^3.0.0",
"bin-wrapper": "^4.0.0",
@ -11965,9 +11877,6 @@
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ow/node_modules/type-fest": {
@ -11976,9 +11885,6 @@
"integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-cancelable": {
@ -12088,9 +11994,6 @@
"dev": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-reduce": {
@ -12367,7 +12270,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngquant-bin/-/pngquant-bin-6.0.0.tgz",
"integrity": "sha512-oXWAS9MQ9iiDAJRdAZ9KO1mC5UwhzKkJsmetiu0iqIjJuW7JsuLhmc4JdRm7uJkIWRzIAou/Vq2VcjfJwz30Ow==",
"hasInstallScript": true,
"dependencies": {
"bin-build": "^3.0.0",
"bin-wrapper": "^4.0.1",
@ -12411,9 +12313,6 @@
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/pngquant-bin/node_modules/get-stream": {
@ -12425,9 +12324,6 @@
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pngquant-bin/node_modules/is-stream": {
@ -12511,10 +12407,6 @@
},
"engines": {
"node": "^10 || ^12 || >=14"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
}
},
"node_modules/postcss-modules": {
@ -12986,17 +12878,6 @@
"loose-envify": "^1.4.0",
"prop-types": "^15.7.2",
"react-is": "^16.13.1"
},
"peerDependencies": {
"react": "^16.8.3 || ^17"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-router": {
@ -13045,20 +12926,14 @@
}
},
"node_modules/react-simplemde-editor": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/react-simplemde-editor/-/react-simplemde-editor-4.1.3.tgz",
"integrity": "sha512-MJ3SDYfYsNnEcmLzQCqPERDaarllwbxR06oyOQ+jJn0517HYIcQCfFoOIT4uewRY14g05n/Ux1Nka88Bocrdcg==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-simplemde-editor/-/react-simplemde-editor-5.0.1.tgz",
"integrity": "sha512-qFWxcBo9P6B8vfdPDyuaOlfwM9+ExLXplWAR2r/Drc1wGftQbwCDlSzgVlp8R0vsIBFdm+OtR8fxk4mJJKn0OA==",
"dependencies": {
"@types/codemirror": "^0.0.88",
"@types/marked": "^0.7.4",
"easymde": "^2.10.1"
"@types/codemirror": "0.0.109",
"@types/marked": "^2.0.2"
}
},
"node_modules/react-simplemde-editor/node_modules/@types/marked": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-0.7.4.tgz",
"integrity": "sha512-fdg0NO4qpuHWtZk6dASgsrBggY+8N4dWthl1bAQG9ceKUNKFjqpHaDKCAhRUI6y8vavG7hLSJ4YBwJtZyZEXqw=="
},
"node_modules/react-transition-group": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
@ -14377,6 +14252,11 @@
"safer-buffer": "^2.0.2",
"tweetnacl": "~0.14.0"
},
"bin": {
"sshpk-conv": "bin/sshpk-conv",
"sshpk-sign": "bin/sshpk-sign",
"sshpk-verify": "bin/sshpk-verify"
},
"engines": {
"node": ">=0.10.0"
}
@ -15649,20 +15529,6 @@
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
@ -15848,9 +15714,6 @@
"es-abstract": "^1.17.2",
"has-symbols": "^1.0.1",
"object.getownpropertydescriptors": "^2.1.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/util/node_modules/inherits": {
@ -17969,9 +17832,9 @@
}
},
"@types/codemirror": {
"version": "0.0.88",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.88.tgz",
"integrity": "sha512-FI9BvlO+SIEmKoIdrS9uphasiHJ/JbeUsAbVTdklBOcmnr/bQpJ6QaCw540FY98LGdDNl1Nyn7erGxo0eD2gOg==",
"version": "0.0.109",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.109.tgz",
"integrity": "sha512-cSdiHeeLjvGn649lRTNeYrVCDOgDrtP+bDDSFDd1TF+i0jKGPDRozno2NOJ9lTniso+taiv4kiVS8dgM8Jm5lg==",
"requires": {
"@types/tern": "*"
}
@ -18118,10 +17981,9 @@
}
},
"@types/marked": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-2.0.0.tgz",
"integrity": "sha512-kSOVa3R6HJvFdd3UIbTYvrSBTPHjXhNErh7/8oSCKOwqdOkk4Oj8N77n+f6dsgd1jW3j3SU5EhnmRxPhNKOmtQ==",
"dev": true
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-2.0.4.tgz",
"integrity": "sha512-L9VRSe0Id8xbPL99mUo/4aKgD7ZoRwFZqUQScNKHi2pFjF9ZYSMNShUHD6VlMT6J/prQq0T1mxuU25m3R7dFzg=="
},
"@types/minimatch": {
"version": "3.0.4",
@ -18277,9 +18139,9 @@
"dev": true
},
"@types/tern": {
"version": "0.23.3",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.3.tgz",
"integrity": "sha512-imDtS4TAoTcXk0g7u4kkWqedB3E4qpjXzCpD2LU5M5NAXHzCDsypyvXSaG7mM8DKYkCRa7tFp4tS/lp/Wo7Q3w==",
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.4.tgz",
"integrity": "sha512-JAUw1iXGO1qaWwEOzxTKJZ/5JxVeON9kvGZ/osgZaJImBnyjyn0cjovPsf6FNLmyGY8Vw9DoXZCMlfMkMwHRWg==",
"requires": {
"@types/estree": "*"
}
@ -20132,9 +19994,9 @@
"optional": true
},
"codemirror": {
"version": "5.59.4",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.59.4.tgz",
"integrity": "sha512-achw5JBgx8QPcACDDn+EUUXmCYzx/zxEtOGXyjvLEvYY8GleUrnfm5D+Zb+UjShHggXKDT9AXrbkBZX6a0YSQg=="
"version": "5.62.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.62.2.tgz",
"integrity": "sha512-tVFMUa4J3Q8JUd1KL9yQzQB0/BJt7ZYZujZmTPgo/54Lpuq3ez4C8x/ATUY/wv7b7X3AUq8o3Xd+2C5ZrCGWHw=="
},
"codemirror-spell-checker": {
"version": "1.1.2",
@ -21273,13 +21135,22 @@
"integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI="
},
"easymde": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.14.0.tgz",
"integrity": "sha512-yQh3EF1amknaxDhXE1L28kwknREU8S19o01ki0t6Q8ThECCipXTOM3E/LL32Ia5D3AsCBRbC1/fT5tpLniVGuw==",
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.15.0.tgz",
"integrity": "sha512-9jMRIVvKt1d0UjRN45yotUYECAM4xvw0TTAQw8sYDONP++keWJVnd8Xrn+V+vQEN/v9/X0SWEoo1rFSgCooGpw==",
"requires": {
"codemirror": "^5.59.2",
"@types/codemirror": "0.0.109",
"@types/marked": "^2.0.2",
"codemirror": "^5.61.0",
"codemirror-spell-checker": "1.1.2",
"marked": "^2.0.0"
"marked": "^2.0.3"
},
"dependencies": {
"marked": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-2.1.3.tgz",
"integrity": "sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA=="
}
}
},
"ecc-jsbn": {
@ -27286,20 +27157,12 @@
}
},
"react-simplemde-editor": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/react-simplemde-editor/-/react-simplemde-editor-4.1.3.tgz",
"integrity": "sha512-MJ3SDYfYsNnEcmLzQCqPERDaarllwbxR06oyOQ+jJn0517HYIcQCfFoOIT4uewRY14g05n/Ux1Nka88Bocrdcg==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-simplemde-editor/-/react-simplemde-editor-5.0.1.tgz",
"integrity": "sha512-qFWxcBo9P6B8vfdPDyuaOlfwM9+ExLXplWAR2r/Drc1wGftQbwCDlSzgVlp8R0vsIBFdm+OtR8fxk4mJJKn0OA==",
"requires": {
"@types/codemirror": "^0.0.88",
"@types/marked": "^0.7.4",
"easymde": "^2.10.1"
},
"dependencies": {
"@types/marked": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-0.7.4.tgz",
"integrity": "sha512-fdg0NO4qpuHWtZk6dASgsrBggY+8N4dWthl1bAQG9ceKUNKFjqpHaDKCAhRUI6y8vavG7hLSJ4YBwJtZyZEXqw=="
}
"@types/codemirror": "0.0.109",
"@types/marked": "^2.0.2"
}
},
"react-transition-group": {

View File

@ -25,6 +25,7 @@
},
"dependencies": {
"@reduxjs/toolkit": "^1.6.0",
"easymde": "^2.15.0",
"emoji-mart": "^3.0.1",
"imagemin-gifsicle": "^7.0.0",
"imagemin-mozjpeg": "^9.0.0",
@ -48,7 +49,7 @@
"react-redux": "^7.2.4",
"react-router-dom": "^5.2.0",
"react-select": "^4.3.0",
"react-simplemde-editor": "^4.1.3"
"react-simplemde-editor": "^5.0.1"
},
"jest": {
"moduleNameMapper": {

View File

@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react'
import React, {useEffect} from 'react'
import {
BrowserRouter as Router,
Redirect,
@ -20,28 +20,25 @@ import DashboardPage from './pages/dashboardPage'
import ErrorPage from './pages/errorPage'
import LoginPage from './pages/loginPage'
import RegisterPage from './pages/registerPage'
import {IUser} from './user'
import {Utils} from './utils'
import wsClient from './wsclient'
import {importNativeAppSettings} from './nativeApp'
import {fetchCurrentUser, getCurrentUser} from './store/currentUser'
import {fetchMe, getLoggedIn} from './store/users'
import {getLanguage, fetchLanguage} from './store/language'
import {setGlobalError, getGlobalError} from './store/globalError'
import {useAppSelector, useAppDispatch} from './store/hooks'
const App = React.memo((): JSX.Element => {
importNativeAppSettings()
const language = useAppSelector<string>(getLanguage)
const user = useAppSelector<IUser|null>(getCurrentUser)
const loggedIn = useAppSelector<boolean|null>(getLoggedIn)
const globalError = useAppSelector<string>(getGlobalError)
const dispatch = useAppDispatch()
const [initialLoad, setInitialLoad] = useState(false)
useEffect(() => {
dispatch(fetchLanguage())
dispatch(fetchCurrentUser()).then(() => {
setInitialLoad(true)
})
dispatch(fetchMe())
}, [])
useEffect(() => {
@ -51,6 +48,12 @@ const App = React.memo((): JSX.Element => {
}
}, [])
let globalErrorRedirect = null
if (globalError) {
globalErrorRedirect = <Route path='/*'><Redirect to={`/error?id=${globalError}`}/></Route>
setTimeout(() => dispatch(setGlobalError('')), 0)
}
return (
<IntlProvider
locale={language.split(/[_]/)[0]}
@ -62,6 +65,7 @@ const App = React.memo((): JSX.Element => {
<div id='frame'>
<div id='main'>
<Switch>
{globalErrorRedirect}
<Route path='/error'>
<ErrorPage/>
</Route>
@ -78,8 +82,8 @@ const App = React.memo((): JSX.Element => {
<BoardPage readonly={true}/>
</Route>
<Route path='/board/:boardId?/:viewId?'>
{initialLoad && !user && <Redirect to='/login'/>}
<BoardPage/>
{loggedIn === false && <Redirect to='/login'/>}
{loggedIn === true && <BoardPage/>}
</Route>
<Route path='/workspace/:workspaceId/shared/:boardId?/:viewId?'>
<BoardPage readonly={true}/>
@ -87,17 +91,19 @@ const App = React.memo((): JSX.Element => {
<Route
path='/workspace/:workspaceId/:boardId?/:viewId?'
render={({match}) => {
if (initialLoad && !user) {
if (loggedIn === false) {
let redirectUrl = '/' + Utils.buildURL(`/workspace/${match.params.workspaceId}/`)
if (redirectUrl.indexOf('//') === 0) {
redirectUrl = redirectUrl.slice(1)
}
const loginUrl = `/login?r=${encodeURIComponent(redirectUrl)}`
return <Redirect to={loginUrl}/>
}
} else if (loggedIn === true) {
return (
<BoardPage/>
)
}
return null
}}
/>
<Route
@ -107,8 +113,8 @@ const App = React.memo((): JSX.Element => {
<DashboardPage/>
</Route>
<Route path='/:boardId?/:viewId?'>
{initialLoad && !user && <Redirect to='/login'/>}
<BoardPage/>
{loggedIn === false && <Redirect to='/login'/>}
{loggedIn === true && <BoardPage/>}
</Route>
</Switch>
</div>

View File

@ -1,15 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ArchiveUtils, IArchiveHeader, IArchiveLine, IBlockArchiveLine} from './blocks/archive'
import {IBlock} from './blocks/block'
import {ArchiveUtils, ArchiveHeader, ArchiveLine, BlockArchiveLine} from './blocks/archive'
import {Block} from './blocks/block'
import {Board} from './blocks/board'
import {LineReader} from './lineReader'
import mutator from './mutator'
import {Utils} from './utils'
import {BoardTree} from './viewModel/boardTree'
class Archiver {
static async exportBoardArchive(boardTree: BoardTree): Promise<void> {
const blocks = await mutator.exportArchive(boardTree.board.id)
static async exportBoardArchive(board: Board): Promise<void> {
const blocks = await mutator.exportArchive(board.id)
this.exportArchive(blocks)
}
@ -18,7 +18,7 @@ class Archiver {
this.exportArchive(blocks)
}
private static exportArchive(blocks: readonly IBlock[]): void {
private static exportArchive(blocks: readonly Block[]): void {
const content = ArchiveUtils.buildBlockArchive(blocks)
const date = new Date()
@ -45,7 +45,7 @@ class Archiver {
private static async importBlocksFromFile(file: File): Promise<void> {
let blockCount = 0
const maxBlocksPerImport = 1000
let blocks: IBlock[] = []
let blocks: Block[] = []
let isFirstLine = true
return new Promise<void>((resolve) => {
@ -62,20 +62,20 @@ class Archiver {
if (isFirstLine) {
isFirstLine = false
const header = JSON.parse(line) as IArchiveHeader
const header = JSON.parse(line) as ArchiveHeader
if (header.date && header.version >= 1) {
const date = new Date(header.date)
Utils.log(`Import archive, version: ${header.version}, date/time: ${date.toLocaleString()}.`)
}
} else {
const row = JSON.parse(line) as IArchiveLine
const row = JSON.parse(line) as ArchiveLine
if (!row || !row.type || !row.data) {
Utils.logError('importFullArchive ERROR parsing line')
return
}
switch (row.type) {
case 'block': {
const blockLine = row as IBlockArchiveLine
const blockLine = row as BlockArchiveLine
const block = blockLine.data
if (Archiver.isValidBlock(block)) {
blocks.push(block)
@ -94,7 +94,7 @@ class Archiver {
})
}
static isValidBlock(block: IBlock): boolean {
static isValidBlock(block: Block): boolean {
if (!block.id || !block.rootId) {
return false
}

View File

@ -3,10 +3,10 @@
import {TestBlockFactory} from '../test/testBlockFactory'
import {ArchiveUtils} from './archive'
import {IBlock} from './block'
import {Block} from './block'
test('archive: archive and unarchive', async () => {
const blocks: IBlock[] = []
const blocks: Block[] = []
const board = TestBlockFactory.createBoard()
blocks.push(board)

View File

@ -1,26 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IBlock} from './block'
import {Block} from './block'
interface IArchiveHeader {
interface ArchiveHeader {
version: number
date: number
}
interface IArchiveLine {
interface ArchiveLine {
type: string,
data: any,
}
// This schema allows the expansion of additional line types in the future
interface IBlockArchiveLine extends IArchiveLine {
interface BlockArchiveLine extends ArchiveLine {
type: 'block',
data: IBlock
data: Block
}
class ArchiveUtils {
static buildBlockArchive(blocks: readonly IBlock[]): string {
const header: IArchiveHeader = {
static buildBlockArchive(blocks: readonly Block[]): string {
const header: ArchiveHeader = {
version: 1,
date: Date.now(),
}
@ -28,7 +28,7 @@ class ArchiveUtils {
const headerString = JSON.stringify(header)
let content = headerString + '\n'
for (const block of blocks) {
const line: IBlockArchiveLine = {
const line: BlockArchiveLine = {
type: 'block',
data: block,
}
@ -40,12 +40,12 @@ class ArchiveUtils {
return content
}
static parseBlockArchive(contents: string): IBlock[] {
const blocks: IBlock[] = []
static parseBlockArchive(contents: string): Block[] {
const blocks: Block[] = []
const allLineStrings = contents.split('\n')
if (allLineStrings.length >= 2) {
const headerString = allLineStrings[0]
const header = JSON.parse(headerString) as IArchiveHeader
const header = JSON.parse(headerString) as ArchiveHeader
if (header.date && header.version >= 1) {
const lineStrings = allLineStrings.slice(1)
let lineNum = 2
@ -54,13 +54,13 @@ class ArchiveUtils {
// Ignore empty lines, e.g. last line
continue
}
const line = JSON.parse(lineString) as IArchiveLine
const line = JSON.parse(lineString) as ArchiveLine
if (!line || !line.type || !line.data) {
throw new Error(`ERROR parsing line ${lineNum}`)
}
switch (line.type) {
case 'block': {
const blockLine = line as IBlockArchiveLine
const blockLine = line as BlockArchiveLine
const block = blockLine.data
blocks.push(block)
break
@ -78,4 +78,4 @@ class ArchiveUtils {
}
}
export {IArchiveHeader, IArchiveLine, IBlockArchiveLine, ArchiveUtils}
export {ArchiveHeader, ArchiveLine, BlockArchiveLine, ArchiveUtils}

View File

@ -1,85 +0,0 @@
// 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.fileId.length).toBeGreaterThan(0)
expect(blockB.fileId).toEqual(blockA.fileId)
})
test('block: clone divider', async () => {
const card = TestBlockFactory.createCard()
const blockA = TestBlockFactory.createDivider(card)
const blockB = new MutableDividerBlock(blockA)
expect(blockB).toEqual(blockA)
})

View File

@ -1,30 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Utils} from '../utils'
const contentBlockTypes = ['text', 'image', 'divider', 'checkbox'] as const
const blockTypes = [...contentBlockTypes, 'board', 'view', 'card', 'comment'] as const
const blockTypes = [...contentBlockTypes, 'board', 'view', 'card', 'comment', 'unknown'] as const
type ContentBlockTypes = typeof contentBlockTypes[number]
type BlockTypes = typeof blockTypes[number]
interface IBlock {
readonly id: string
readonly parentId: string
readonly rootId: string
readonly createdBy: string
readonly modifiedBy: string
readonly schema: number
readonly type: BlockTypes
readonly title: string
readonly fields: Readonly<Record<string, any>>
readonly createAt: number
readonly updateAt: number
readonly deleteAt: number
}
interface IMutableBlock extends IBlock {
interface Block {
id: string
parentId: string
rootId: string
@ -41,40 +25,23 @@ interface IMutableBlock extends IBlock {
deleteAt: number
}
class MutableBlock implements IMutableBlock {
id: string = Utils.createGuid()
schema: number
parentId: string
rootId: string
createdBy: string
modifiedBy: string
type: BlockTypes
title: string
fields: Record<string, any> = {}
createAt: number = Date.now()
updateAt = 0
deleteAt = 0
constructor(block: any = {}) {
this.id = block.id || Utils.createGuid()
this.schema = 1
this.parentId = block.parentId || ''
this.rootId = block.rootId || ''
this.createdBy = block.createdBy || ''
this.modifiedBy = block.modifiedBy || ''
this.type = block.type || ''
// Shallow copy here. Derived classes must make deep copies of their known properties in their constructors.
this.fields = block.fields ? {...block.fields} : {}
this.title = block.title || ''
function createBlock(block?: Block): Block {
const now = Date.now()
this.createAt = block.createAt || now
this.updateAt = block.updateAt || now
this.deleteAt = block.deleteAt || 0
return {
id: block?.id || Utils.createGuid(),
schema: 1,
parentId: block?.parentId || '',
rootId: block?.rootId || '',
createdBy: block?.createdBy || '',
modifiedBy: block?.modifiedBy || '',
type: block?.type || 'unknown',
fields: block?.fields ? {...block?.fields} : {},
title: block?.title || '',
createAt: block?.createAt || now,
updateAt: block?.updateAt || now,
deleteAt: block?.deleteAt || 0,
}
}
export type {ContentBlockTypes, BlockTypes}
export {blockTypes, contentBlockTypes, IBlock, IMutableBlock, MutableBlock}
export {blockTypes, contentBlockTypes, Block, createBlock}

View File

@ -2,17 +2,12 @@
// See LICENSE.txt for license information.
import {Utils} from '../utils'
import {IBlock, MutableBlock} from './block'
import {Block, createBlock} from './block'
import {Card} from './card'
type PropertyType = 'text' | 'number' | 'select' | 'multiSelect' | 'date' | 'person' | 'file' | 'checkbox' | 'url' | 'email' | 'phone' | 'createdTime' | 'createdBy' | 'updatedTime' | 'updatedBy'
interface IPropertyOption {
readonly id: string
readonly value: string
readonly color: string
}
interface IMutablePropertyOption {
id: string
value: string
color: string
@ -20,73 +15,40 @@ interface IMutablePropertyOption {
// A template for card properties attached to a board
interface IPropertyTemplate {
readonly id: string
readonly name: string
readonly type: PropertyType
readonly options: IPropertyOption[]
}
interface IMutablePropertyTemplate extends IPropertyTemplate {
id: string
name: string
type: PropertyType
options: IMutablePropertyOption[]
options: IPropertyOption[]
}
interface Board extends IBlock {
readonly icon: string
readonly description: string
readonly showDescription: boolean
readonly isTemplate: boolean
readonly cardProperties: readonly IPropertyTemplate[]
duplicate(): MutableBoard
type BoardFields = {
icon: string
description: string
showDescription?: boolean
isTemplate?: boolean
cardProperties: IPropertyTemplate[]
}
class MutableBoard extends MutableBlock implements Board {
get icon(): string {
return this.fields.icon as string
}
set icon(value: string) {
this.fields.icon = value
type Board = Block & {
fields: BoardFields
}
get description(): string {
return this.fields.description as string
function createBoard(block?: Block): Board {
let cardProperties: IPropertyTemplate[] = []
const selectProperties = cardProperties.find((o) => o.type === 'select')
if (!selectProperties) {
const property: IPropertyTemplate = {
id: Utils.createGuid(),
name: 'Status',
type: 'select',
options: [],
}
set description(value: string) {
this.fields.description = value
cardProperties.push(property)
}
get showDescription(): boolean {
return Boolean(this.fields.showDescription)
}
set showDescription(value: boolean) {
this.fields.showDescription = value
}
get isTemplate(): boolean {
return Boolean(this.fields.isTemplate)
}
set isTemplate(value: boolean) {
this.fields.isTemplate = value
}
get cardProperties(): IMutablePropertyTemplate[] {
return this.fields.cardProperties as IPropertyTemplate[]
}
set cardProperties(value: IMutablePropertyTemplate[]) {
this.fields.cardProperties = value
}
constructor(block: any = {}) {
super(block)
this.type = 'board'
this.icon = block.fields?.icon || ''
this.description = block.fields?.description || ''
if (block.fields?.cardProperties) {
if (block?.fields.cardProperties) {
// Deep clone of card properties and their options
this.cardProperties = block.fields.cardProperties.map((o: IPropertyTemplate) => {
cardProperties = block?.fields.cardProperties.map((o: IPropertyTemplate) => {
return {
id: o.id,
name: o.name,
@ -94,16 +56,24 @@ class MutableBoard extends MutableBlock implements Board {
options: o.options ? o.options.map((option) => ({...option})) : [],
}
})
} else {
this.cardProperties = []
}
return {
...createBlock(block),
type: 'board',
fields: {
showDescription: block?.fields.showDescription || false,
description: block?.fields.description || '',
icon: block?.fields.icon || '',
isTemplate: block?.fields.isTemplate || false,
cardProperties,
},
}
}
duplicate(): MutableBoard {
const board = new MutableBoard(this)
board.id = Utils.createGuid()
return board
}
type BoardGroup = {
option: IPropertyOption
cards: Card[]
}
export {Board, MutableBoard, PropertyType, IPropertyOption, IPropertyTemplate}
export {Board, PropertyType, IPropertyOption, IPropertyTemplate, BoardGroup, createBoard}

View File

@ -1,12 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {MutableBoardView, sortBoardViewsAlphabetically} from './boardView'
import {TestBlockFactory} from '../test/testBlockFactory'
import {sortBoardViewsAlphabetically} from './boardView'
test('boardView: sort with ASCII', async () => {
const view1 = new MutableBoardView()
const view1 = TestBlockFactory.createBoardView()
view1.title = 'Maybe'
const view2 = new MutableBoardView()
const view2 = TestBlockFactory.createBoardView()
view2.title = 'Active'
const views = [view1, view2]
@ -15,9 +17,9 @@ test('boardView: sort with ASCII', async () => {
})
test('boardView: sort with leading emoji', async () => {
const view1 = new MutableBoardView()
const view1 = TestBlockFactory.createBoardView()
view1.title = '🤔 Maybe'
const view2 = new MutableBoardView()
const view2 = TestBlockFactory.createBoardView()
view2.title = '🚀 Active'
const views = [view1, view2]
@ -26,9 +28,9 @@ test('boardView: sort with leading emoji', async () => {
})
test('boardView: sort with non-latin characters', async () => {
const view1 = new MutableBoardView()
const view1 = TestBlockFactory.createBoardView()
view1.title = 'zebra'
const view2 = new MutableBoardView()
const view2 = TestBlockFactory.createBoardView()
view2.title = 'ñu'
const views = [view1, view2]

View File

@ -1,122 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Utils} from '../utils'
import {IBlock, MutableBlock} from './block'
import {FilterGroup} from './filterGroup'
import {Block, createBlock} from './block'
import {FilterGroup, createFilterGroup} from './filterGroup'
type IViewType = 'board' | 'table' | 'gallery' // | 'calendar' | 'list'
type ISortOption = { propertyId: '__title' | string, reversed: boolean }
interface BoardView extends IBlock {
readonly viewType: IViewType
readonly groupById?: string
readonly sortOptions: readonly ISortOption[]
readonly visiblePropertyIds: readonly string[]
readonly visibleOptionIds: readonly string[]
readonly hiddenOptionIds: readonly string[]
readonly collapsedOptionIds: readonly string[]
readonly filter: FilterGroup
readonly cardOrder: readonly string[]
readonly columnWidths: Readonly<Record<string, number>>
duplicate(): MutableBoardView
type BoardViewFields = {
viewType: IViewType
groupById?: string
sortOptions: ISortOption[]
visiblePropertyIds: string[]
visibleOptionIds: string[]
hiddenOptionIds: string[]
collapsedOptionIds: string[]
filter: FilterGroup
cardOrder: string[]
columnWidths: Record<string, number>
}
class MutableBoardView extends MutableBlock implements BoardView {
get viewType(): IViewType {
return this.fields.viewType
}
set viewType(value: IViewType) {
this.fields.viewType = value
type BoardView = Block & {
fields: BoardViewFields
}
get groupById(): string | undefined {
return this.fields.groupById
}
set groupById(value: string | undefined) {
this.fields.groupById = value
}
get sortOptions(): ISortOption[] {
return this.fields.sortOptions
}
set sortOptions(value: ISortOption[]) {
this.fields.sortOptions = value
}
get visiblePropertyIds(): string[] {
return this.fields.visiblePropertyIds
}
set visiblePropertyIds(value: string[]) {
this.fields.visiblePropertyIds = value
}
get visibleOptionIds(): string[] {
return this.fields.visibleOptionIds
}
set visibleOptionIds(value: string[]) {
this.fields.visibleOptionIds = value
}
get hiddenOptionIds(): string[] {
return this.fields.hiddenOptionIds
}
set hiddenOptionIds(value: string[]) {
this.fields.hiddenOptionIds = value
}
get collapsedOptionIds(): string[] {
return this.fields.collapsedOptionIds
}
set collapsedOptionIds(value: string[]) {
this.fields.collapsedOptionIds = value
}
get filter(): FilterGroup {
return this.fields.filter
}
set filter(value: FilterGroup) {
this.fields.filter = value
}
get cardOrder(): string[] {
return this.fields.cardOrder
}
set cardOrder(value: string[]) {
this.fields.cardOrder = value
}
get columnWidths(): Record<string, number> {
return this.fields.columnWidths as Record<string, number>
}
set columnWidths(value: Record<string, number>) {
this.fields.columnWidths = value
}
constructor(block: any = {}) {
super(block)
this.type = 'view'
this.sortOptions = block.fields?.sortOptions?.map((o: ISortOption) => ({...o})) || [] // Deep clone
this.visiblePropertyIds = block.fields?.visiblePropertyIds?.slice() || []
this.visibleOptionIds = block.fields?.visibleOptionIds?.slice() || []
this.hiddenOptionIds = block.fields?.hiddenOptionIds?.slice() || []
this.collapsedOptionIds = block.fields?.collapsedOptionIds?.slice() || []
this.filter = new FilterGroup(block.fields?.filter)
this.cardOrder = block.fields?.cardOrder?.slice() || []
this.columnWidths = {...(block.fields?.columnWidths || {})}
if (!this.viewType) {
this.viewType = 'board'
}
}
duplicate(): MutableBoardView {
const view = new MutableBoardView(this)
view.id = Utils.createGuid()
return view
function createBoardView(block?: Block): BoardView {
return {
...createBlock(block),
type: 'view',
fields: {
viewType: block?.fields.viewType || 'board',
groupById: block?.fields.groupById,
sortOptions: block?.fields.sortOptions?.map((o: ISortOption) => ({...o})) || [],
visiblePropertyIds: block?.fields.visiblePropertyIds?.slice() || [],
visibleOptionIds: block?.fields.visibleOptionIds?.slice() || [],
hiddenOptionIds: block?.fields.hiddenOptionIds?.slice() || [],
collapsedOptionIds: block?.fields.collapsedOptionIds?.slice() || [],
filter: createFilterGroup(block?.fields.filter),
cardOrder: block?.fields.cardOrder?.slice() || [],
columnWidths: {...(block?.fields.columnWidths || {})},
},
}
}
@ -127,4 +50,4 @@ function sortBoardViewsAlphabetically(views: BoardView[]): BoardView[] {
}).sort((v1, v2) => v1.title.localeCompare(v2.title)).map((v) => v.view)
}
export {BoardView, MutableBoardView, IViewType, ISortOption, sortBoardViewsAlphabetically}
export {BoardView, IViewType, ISortOption, sortBoardViewsAlphabetically, createBoardView}

View File

@ -1,61 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Utils} from '../utils'
import {IBlock, MutableBlock} from './block'
import {Block, createBlock} from './block'
interface Card extends IBlock {
readonly icon: string
readonly isTemplate: boolean
readonly properties: Readonly<Record<string, string | string[]>>
readonly contentOrder: Readonly<Array<string | string[]>>
duplicate(): MutableCard
type CardFields = {
icon?: string
isTemplate?: boolean
properties: Record<string, string | string[]>
contentOrder: Array<string | string[]>
}
class MutableCard extends MutableBlock implements Card {
get icon(): string {
return this.fields.icon as string
}
set icon(value: string) {
this.fields.icon = value
type Card = Block & {
fields: CardFields
}
get isTemplate(): boolean {
return Boolean(this.fields.isTemplate)
function createCard(block?: Block): Card {
const contentOrder: Array<string|string[]> = []
if (block?.fields.contentOrder) {
for (const contentId of block.fields.contentOrder) {
if (typeof contentId === 'string') {
contentOrder.push(contentId)
} else {
contentOrder.push(contentId.slice())
}
set isTemplate(value: boolean) {
this.fields.isTemplate = value
}
get properties(): Record<string, string | string[]> {
return this.fields.properties as Record<string, string | string[]>
}
set properties(value: Record<string, string | string[]>) {
this.fields.properties = value
}
get contentOrder(): Array<string | string[]> {
return this.fields.contentOrder
}
set contentOrder(value: Array<string | 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 {
const card = new MutableCard(this)
card.id = Utils.createGuid()
return card
return {
...createBlock(block),
type: 'card',
fields: {
icon: block?.fields.icon || '',
properties: {...(block?.fields.properties || {})},
contentOrder,
isTemplate: block?.fields.isTemplate || false,
},
}
}
export {MutableCard, Card}
export {Card, createCard}

View File

@ -1,14 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IContentBlock, MutableContentBlock} from './contentBlock'
import {ContentBlock} from './contentBlock'
import {Block, createBlock} from './block'
type CheckboxBlock = IContentBlock
type CheckboxBlock = ContentBlock & {
type: 'checkbox'
}
class MutableCheckboxBlock extends MutableContentBlock implements CheckboxBlock {
constructor(block: any = {}) {
super(block)
this.type = 'checkbox'
function createCheckboxBlock(block?: Block): CheckboxBlock {
return {
...createBlock(block),
type: 'checkbox',
}
}
export {CheckboxBlock, MutableCheckboxBlock}
export {CheckboxBlock, createCheckboxBlock}

View File

@ -1,14 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IBlock, MutableBlock} from './block'
import {Block, createBlock} from './block'
type CommentBlock = IBlock
type CommentBlock = Block & {
type: 'comment'
}
class MutableCommentBlock extends MutableBlock implements CommentBlock {
constructor(block: any = {}) {
super(block)
this.type = 'comment'
function createCommentBlock(block?: Block): CommentBlock {
return {
...createBlock(block),
type: 'comment',
}
}
export {CommentBlock, MutableCommentBlock}
export {CommentBlock, createCommentBlock}

View File

@ -1,11 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IBlock, MutableBlock} from './block'
import {Block, createBlock} from './block'
type IContentBlock = IBlock
type IContentBlockWithCords = {block: IBlock, cords: {x: number, y?: number, z?: number}}
class MutableContentBlock extends MutableBlock implements IContentBlock {
type IContentBlockWithCords = {
block: Block,
cords: {x: number, y?: number, z?: number}
}
export {IContentBlock, IContentBlockWithCords, MutableContentBlock}
type ContentBlock = Block
const createContentBlock = createBlock
export {ContentBlock, IContentBlockWithCords, createContentBlock}

View File

@ -1,14 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IContentBlock, MutableContentBlock} from './contentBlock'
import {Block, createBlock} from './block'
import {ContentBlock} from './contentBlock'
type DividerBlock = IContentBlock
type DividerBlock = ContentBlock & {
type: 'divider'
}
class MutableDividerBlock extends MutableContentBlock implements DividerBlock {
constructor(block: any = {}) {
super(block)
this.type = 'divider'
function createDividerBlock(block?: Block): DividerBlock {
return {
...createBlock(block),
type: 'divider',
}
}
export {DividerBlock, MutableDividerBlock}
export {DividerBlock, createDividerBlock}

View File

@ -4,24 +4,26 @@ import {Utils} from '../utils'
type FilterCondition = 'includes' | 'notIncludes' | 'isEmpty' | 'isNotEmpty'
class FilterClause {
type FilterClause = {
propertyId: string
condition: FilterCondition
values: string[]
constructor(o: any = {}) {
this.propertyId = o.propertyId || ''
this.condition = o.condition || 'includes'
this.values = o.values?.slice() || []
}
isEqual(o: FilterClause): boolean {
function createFilterClause(o?: FilterClause): FilterClause {
return {
propertyId: o?.propertyId || '',
condition: o?.condition || 'includes',
values: o?.values?.slice() || [],
}
}
function areEqual(a: FilterClause, b: FilterClause): boolean {
return (
this.propertyId === o.propertyId &&
this.condition === o.condition &&
Utils.arraysEqual(this.values, o.values)
a.propertyId === b.propertyId &&
a.condition === b.condition &&
Utils.arraysEqual(a.values, b.values)
)
}
}
export {FilterClause, FilterCondition}
export {FilterClause, FilterCondition, createFilterClause, areEqual}

View File

@ -1,31 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {FilterClause} from './filterClause'
import {FilterClause, createFilterClause} from './filterClause'
type FilterGroupOperation = 'and' | 'or'
// A FilterGroup has 2 forms: (A or B or C) OR (A and B and C)
class FilterGroup {
operation: FilterGroupOperation = 'and'
filters: (FilterClause | FilterGroup)[] = []
type FilterGroup = {
operation: FilterGroupOperation
filters: (FilterClause | FilterGroup)[]
}
static isAnInstanceOf(object: any): object is FilterGroup {
function isAFilterGroupInstance(object: (FilterClause | FilterGroup)): object is FilterGroup {
return 'innerOperation' in object && 'filters' in object
}
constructor(o: any = {}) {
this.operation = o.operation || 'and'
if (o.filters) {
this.filters = o.filters.map((p: any) => {
if (FilterGroup.isAnInstanceOf(p)) {
return new FilterGroup(p)
function createFilterGroup(o?: FilterGroup): FilterGroup {
let filters: (FilterClause | FilterGroup)[] = []
if (o?.filters) {
filters = o.filters.map((p: (FilterClause | FilterGroup)) => {
if (isAFilterGroupInstance(p)) {
return createFilterGroup(p)
}
return new FilterClause(p)
return createFilterClause(p)
})
} else {
this.filters = []
}
return {
operation: o?.operation || 'and',
filters,
}
}
export {FilterGroup, FilterGroupOperation}
export {FilterGroup, FilterGroupOperation, createFilterGroup, isAFilterGroupInstance}

View File

@ -1,24 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IContentBlock, MutableContentBlock} from './contentBlock'
import {Block, createBlock} from './block'
import {ContentBlock} from './contentBlock'
interface ImageBlock extends IContentBlock {
readonly fileId: string
type ImageBlockFields = {
fileId: string
}
class MutableImageBlock extends MutableContentBlock implements ImageBlock {
get fileId(): string {
return this.fields.fileId as string
}
set fileId(value: string) {
this.fields.fileId = value
type ImageBlock = ContentBlock & {
type: 'image'
fields: ImageBlockFields
}
constructor(block: any = {}) {
super(block)
this.type = 'image'
this.fileId = block.fields?.fileId || ''
function createImageBlock(block?: Block): ImageBlock {
return {
...createBlock(block),
type: 'image',
fields: {
fileId: block?.fields.fileId || '',
},
}
}
export {ImageBlock, MutableImageBlock}
export {ImageBlock, createImageBlock}

View File

@ -1,14 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IContentBlock, MutableContentBlock} from './contentBlock'
import {ContentBlock} from './contentBlock'
import {Block, createBlock} from './block'
type TextBlock = IContentBlock
type TextBlock = ContentBlock & {
type: 'text'
}
class MutableTextBlock extends MutableContentBlock implements TextBlock {
constructor(block: any = {}) {
super(block)
this.type = 'text'
function createTextBlock(block?: Block): TextBlock {
return {
...createBlock(block),
type: 'text',
}
}
export {TextBlock, MutableTextBlock}
export {TextBlock, createTextBlock}

View File

@ -3,7 +3,7 @@
import {IPropertyTemplate} from './blocks/board'
import {Card} from './blocks/card'
import {FilterClause} from './blocks/filterClause'
import {FilterGroup} from './blocks/filterGroup'
import {FilterGroup, isAFilterGroupInstance} from './blocks/filterGroup'
import {Utils} from './utils'
class CardFilter {
@ -20,7 +20,7 @@ class CardFilter {
if (filterGroup.operation === 'or') {
for (const filter of filters) {
if (FilterGroup.isAnInstanceOf(filter)) {
if (isAFilterGroupInstance(filter)) {
if (this.isFilterGroupMet(filter, templates, card)) {
return true
}
@ -32,7 +32,7 @@ class CardFilter {
}
Utils.assert(filterGroup.operation === 'and')
for (const filter of filters) {
if (FilterGroup.isAnInstanceOf(filter)) {
if (isAFilterGroupInstance(filter)) {
if (!this.isFilterGroupMet(filter, templates, card)) {
return false
}
@ -44,7 +44,7 @@ class CardFilter {
}
static isClauseMet(filter: FilterClause, templates: readonly IPropertyTemplate[], card: Card): boolean {
const value = card.properties[filter.propertyId]
const value = card.fields.properties[filter.propertyId]
switch (filter.condition) {
case 'includes': {
if (filter.values?.length < 1) {
@ -77,7 +77,7 @@ class CardFilter {
return {}
}
const filters = filterGroup.filters.filter((o) => !FilterGroup.isAnInstanceOf(o))
const filters = filterGroup.filters.filter((o) => !isAFilterGroupInstance(o))
if (filters.length < 1) {
return {}
}

View File

@ -21,7 +21,7 @@ type Props = {
const AddContentMenuItem = React.memo((props:Props): JSX.Element => {
const {card, type, cords} = props
const index = cords.x
const contentOrder = card.contentOrder.slice()
const contentOrder = card.fields.contentOrder.slice()
const intl = useIntl()
const handler = contentRegistry.getHandler(type)

View File

@ -29,7 +29,7 @@ const BlockIconSelector = React.memo((props: Props) => {
document.body.click()
}, [])
if (!block.icon) {
if (!block.fields.icon) {
return null
}
@ -37,7 +37,7 @@ const BlockIconSelector = React.memo((props: Props) => {
if (props.readonly) {
className += ' readonly'
}
const iconElement = <div className={className}><span>{block.icon}</span></div>
const iconElement = <div className={className}><span>{block.fields.icon}</span></div>
return (
<div className='BlockIconSelector'>

View File

@ -1,12 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useRef, useEffect} from 'react'
import React, {useState, useRef, useEffect, useCallback} from 'react'
import {FormattedMessage} from 'react-intl'
import {BlockIcons} from '../../blockIcons'
import {Card} from '../../blocks/card'
import {BoardView} from '../../blocks/boardView'
import {Board} from '../../blocks/board'
import {CommentBlock} from '../../blocks/commentBlock'
import {ContentBlock} from '../../blocks/contentBlock'
import mutator from '../../mutator'
import {BoardTree} from '../../viewModel/boardTree'
import {CardTree} from '../../viewModel/cardTree'
import Button from '../../widgets/buttons/button'
import {Focusable} from '../../widgets/editable'
import EditableArea from '../../widgets/editableArea'
@ -22,18 +25,29 @@ import CardDetailProperties from './cardDetailProperties'
import './cardDetail.scss'
type Props = {
boardTree: BoardTree
cardTree: CardTree
board: Board
activeView: BoardView
views: BoardView[]
cards: Card[]
card: Card
comments: CommentBlock[]
contents: Array<ContentBlock|ContentBlock[]>
readonly: boolean
}
const CardDetail = (props: Props): JSX.Element|null => {
const {cardTree} = props
const {card, comments} = cardTree
const [title, setTitle] = useState(cardTree.card.title)
const {card, comments} = props
const [title, setTitle] = useState(card.title)
const [serverTitle, setServerTitle] = useState(card.title)
const titleRef = useRef<Focusable>(null)
const titleValueRef = useRef(title)
titleValueRef.current = title
const saveTitle = useCallback(() => {
if (title !== card.title) {
mutator.changeTitle(card, title)
}
}, [card.title, title])
const saveTitleRef = useRef<() => void>(saveTitle)
saveTitleRef.current = saveTitle
useEffect(() => {
if (!title) {
@ -42,14 +56,19 @@ const CardDetail = (props: Props): JSX.Element|null => {
}, [])
useEffect(() => {
return () => {
if (titleValueRef.current !== cardTree?.card.title) {
mutator.changeTitle(card, titleValueRef.current)
if (serverTitle === title) {
setTitle(card.title)
}
}
}, [cardTree])
setServerTitle(card.title)
}, [card.title, title])
if (!cardTree) {
useEffect(() => {
return () => {
saveTitleRef.current && saveTitleRef.current()
}
}, [])
if (!card) {
return null
}
@ -61,7 +80,7 @@ const CardDetail = (props: Props): JSX.Element|null => {
size='l'
readonly={props.readonly}
/>
{!props.readonly && !card.icon &&
{!props.readonly && !card.fields.icon &&
<div className='add-buttons'>
<Button
onClick={() => {
@ -84,12 +103,8 @@ const CardDetail = (props: Props): JSX.Element|null => {
placeholderText='Untitled'
onChange={(newTitle: string) => setTitle(newTitle)}
saveOnEsc={true}
onSave={() => {
if (title !== props.cardTree.card.title) {
mutator.changeTitle(card, title)
}
}}
onCancel={() => setTitle(props.cardTree.card.title)}
onSave={saveTitle}
onCancel={() => setTitle(props.card.title)}
readonly={props.readonly}
spellCheck={true}
/>
@ -97,8 +112,13 @@ const CardDetail = (props: Props): JSX.Element|null => {
{/* Property list */}
<CardDetailProperties
boardTree={props.boardTree}
cardTree={props.cardTree}
board={props.board}
card={props.card}
contents={props.contents}
comments={props.comments}
cards={props.cards}
activeView={props.activeView}
views={props.views}
readonly={props.readonly}
/>
@ -121,13 +141,14 @@ const CardDetail = (props: Props): JSX.Element|null => {
<div className='CardDetail content fullwidth'>
<CardDetailContents
cardTree={props.cardTree}
card={props.card}
contents={props.contents}
readonly={props.readonly}
/>
</div>
{!props.readonly &&
<CardDetailContentsMenu card={props.cardTree.card}/>
<CardDetailContentsMenu card={props.card}/>
}
</>
)

View File

@ -3,11 +3,10 @@
import React from 'react'
import {useIntl, IntlShape} from 'react-intl'
import {IContentBlockWithCords, IContentBlock} from '../../blocks/contentBlock'
import {MutableTextBlock} from '../../blocks/textBlock'
import mutator from '../../mutator'
import {CardTree} from '../../viewModel/cardTree'
import {IContentBlockWithCords, ContentBlock as ContentBlockType} from '../../blocks/contentBlock'
import {Card} from '../../blocks/card'
import {createTextBlock} from '../../blocks/textBlock'
import mutator from '../../mutator'
import {useSortableWithGrip} from '../../hooks/sortable'
import ContentBlock from '../contentBlock'
@ -16,17 +15,18 @@ import {MarkdownEditor} from '../markdownEditor'
export type Position = 'left' | 'right' | 'above' | 'below' | 'aboveRow' | 'belowRow'
type Props = {
cardTree: CardTree
card: Card
contents: Array<ContentBlockType|ContentBlockType[]>
readonly: boolean
}
function addTextBlock(card: Card, intl: IntlShape, text: string): void {
const block = new MutableTextBlock()
const block = createTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.title = text
const contentOrder = card.contentOrder.slice()
const contentOrder = card.fields.contentOrder.slice()
contentOrder.push(block.id)
mutator.performAsUndoGroup(async () => {
const description = intl.formatMessage({id: 'CardDetail.addCardText', defaultMessage: 'add card text'})
@ -36,7 +36,16 @@ function addTextBlock(card: Card, intl: IntlShape, text: string): void {
}
function moveBlock(card: Card, srcBlock: IContentBlockWithCords, dstBlock: IContentBlockWithCords, intl: IntlShape, moveTo: Position): void {
const contentOrder = card.contentOrder.slice()
const contentOrder: Array<string|string[]> = []
if (card.fields.contentOrder) {
for (const contentId of card.fields.contentOrder) {
if (typeof contentId === 'string') {
contentOrder.push(contentId)
} else {
contentOrder.push(contentId.slice())
}
}
}
const srcBlockId = srcBlock.block.id
const dstBlockId = dstBlock.block.id
@ -55,7 +64,7 @@ function moveBlock(card: Card, srcBlock: IContentBlockWithCords, dstBlock: ICont
if (srcBlockY > -1) {
(contentOrder[srcBlockX] as string[]).splice(srcBlockY, 1)
if (contentOrder[srcBlockX].length === 1) {
if (contentOrder[srcBlockX].length === 1 && srcBlockX !== dstBlockX) {
contentOrder.splice(srcBlockX, 1, contentOrder[srcBlockX][0])
}
} else {
@ -99,10 +108,10 @@ function moveBlock(card: Card, srcBlock: IContentBlockWithCords, dstBlock: ICont
}
type ContentBlockWithDragAndDropProps = {
block: IContentBlock | IContentBlock[],
block: ContentBlockType | ContentBlockType[],
x: number,
card: Card,
cardTree: CardTree,
contents: Array<ContentBlockType|ContentBlockType[]>,
intl: IntlShape,
readonly: boolean,
}
@ -129,13 +138,13 @@ const ContentBlockWithDragAndDrop = (props: ContentBlockWithDragAndDropProps) =>
block={b}
card={props.card}
readonly={props.readonly}
width={(1 / (props.block as IContentBlock[]).length) * 100}
width={(1 / (props.block as ContentBlockType[]).length) * 100}
onDrop={(src, dst, moveTo) => moveBlock(props.card, src, dst, props.intl, moveTo)}
cords={{x: props.x, y}}
/>
))}
</div>
{props.x === props.cardTree.contents.length - 1 && (
{props.x === props.contents.length - 1 && (
<div
ref={itemRef2}
className={`addToRow ${isOver2 ? 'dragover' : ''}`}
@ -162,7 +171,7 @@ const ContentBlockWithDragAndDrop = (props: ContentBlockWithDragAndDropProps) =>
onDrop={(src, dst, moveTo) => moveBlock(props.card, src, dst, props.intl, moveTo)}
cords={{x: props.x}}
/>
{props.x === props.cardTree.contents.length - 1 && (
{props.x === props.contents.length - 1 && (
<div
ref={itemRef2}
className={`addToRow ${isOver2 ? 'dragover' : ''}`}
@ -176,22 +185,21 @@ const ContentBlockWithDragAndDrop = (props: ContentBlockWithDragAndDropProps) =>
const CardDetailContents = React.memo((props: Props) => {
const intl = useIntl()
const {cardTree} = props
if (!cardTree) {
const {contents, card} = props
if (!contents) {
return null
}
const {card} = cardTree
if (cardTree.contents.length > 0) {
if (contents.length > 0) {
return (
<div className='octo-content'>
{cardTree.contents.map((block, x) =>
{contents.map((block, x) =>
(
<ContentBlockWithDragAndDrop
key={x}
block={block}
x={x}
card={card}
cardTree={cardTree}
contents={contents}
intl={intl}
readonly={props.readonly}
/>

View File

@ -38,7 +38,7 @@ async function addBlock(card: Card, intl: IntlShape, handler: ContentHandler) {
newBlock.parentId = card.id
newBlock.rootId = card.rootId
const contentOrder = card.contentOrder.slice()
const contentOrder = card.fields.contentOrder.slice()
contentOrder.push(newBlock.id)
const typeName = handler.getDisplayText(intl)
const description = intl.formatMessage({id: 'ContentBlock.addElement', defaultMessage: 'add {type}'}, {type: typeName})

View File

@ -6,12 +6,10 @@ import {fireEvent, render} from '@testing-library/react'
import {IntlProvider} from 'react-intl'
import userEvent from '@testing-library/user-event'
import {MutableBoardTree} from '../../viewModel/boardTree'
import {TestBlockFactory} from '../../test/testBlockFactory'
import {FetchMock} from '../../test/fetchMock'
import 'isomorphic-fetch'
import {MutableCardTree} from '../../viewModel/cardTree'
import CardDetailProperties from './cardDetailProperties'
@ -25,7 +23,7 @@ const wrapIntl = (children: any) => <IntlProvider locale='en'>{children}</IntlPr
describe('components/cardDetail/CardDetailProperties', () => {
const board = TestBlockFactory.createBoard()
board.cardProperties = [
board.fields.cardProperties = [
{
id: 'property_id_1',
name: 'Owner',
@ -51,27 +49,26 @@ describe('components/cardDetail/CardDetailProperties', () => {
]
const view = TestBlockFactory.createBoardView(board)
view.sortOptions = []
view.groupById = undefined
view.hiddenOptionIds = []
view.fields.sortOptions = []
view.fields.groupById = undefined
view.fields.hiddenOptionIds = []
const card = TestBlockFactory.createCard(board)
card.properties.property_id_1 = 'property_value_id_1'
card.fields.properties.property_id_1 = 'property_value_id_1'
const cardTemplate = TestBlockFactory.createCard(board)
cardTemplate.isTemplate = true
const cardTree = new MutableCardTree(card)
cardTemplate.fields.isTemplate = true
test('should match snapshot', async () => {
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).not.toBeUndefined()
const componet = wrapIntl((
<CardDetailProperties
boardTree={boardTree!}
cardTree={cardTree}
board={board!}
card={card}
cards={[card]}
contents={[]}
comments={[]}
activeView={view}
views={[view]}
readonly={false}
/>
))
@ -81,14 +78,15 @@ describe('components/cardDetail/CardDetailProperties', () => {
})
test('rename select property', async () => {
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).not.toBeUndefined()
const component = wrapIntl((
<CardDetailProperties
boardTree={boardTree!}
cardTree={cardTree}
board={board!}
card={card}
cards={[card]}
contents={[]}
comments={[]}
activeView={view}
views={[view]}
readonly={false}
/>
))
@ -103,15 +101,13 @@ describe('components/cardDetail/CardDetailProperties', () => {
userEvent.type(propertyNameInput!, 'Owner - Renamed{enter}')
fireEvent.click(propertyLabel!)
// should be called twice,
// one to sync card tree,
// and once on renaming the property
expect(FetchMock.fn).toBeCalledTimes(2)
// should be called once on renaming the property
expect(FetchMock.fn).toBeCalledTimes(1)
// Verify API call was made with renamed property
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const lastAPICallPayload = JSON.parse(FetchMock.fn.mock.calls[1][1].body)
const lastAPICallPayload = JSON.parse(FetchMock.fn.mock.calls[0][1].body)
expect(lastAPICallPayload[0].fields.cardProperties[0].name).toBe('Owner - Renamed')
expect(lastAPICallPayload[0].fields.cardProperties[0].options.length).toBe(3)
expect(lastAPICallPayload[0].fields.cardProperties[0].options[0].value).toBe('Jean-Luc Picard')

View File

@ -3,10 +3,12 @@
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {PropertyType} from '../../blocks/board'
import {Board, PropertyType, IPropertyTemplate} from '../../blocks/board'
import {Card} from '../../blocks/card'
import {BoardView} from '../../blocks/boardView'
import {ContentBlock} from '../../blocks/contentBlock'
import {CommentBlock} from '../../blocks/commentBlock'
import mutator from '../../mutator'
import {BoardTree} from '../../viewModel/boardTree'
import {CardTree} from '../../viewModel/cardTree'
import Button from '../../widgets/buttons/button'
import MenuWrapper from '../../widgets/menuWrapper'
import PropertyMenu from '../../widgets/propertyMenu'
@ -14,20 +16,23 @@ import PropertyMenu from '../../widgets/propertyMenu'
import PropertyValueElement from '../propertyValueElement'
type Props = {
boardTree: BoardTree
cardTree: CardTree
board: Board
card: Card
cards: Card[]
contents: Array<ContentBlock|ContentBlock[]>
comments: CommentBlock[]
activeView: BoardView
views: BoardView[]
readonly: boolean
}
const CardDetailProperties = React.memo((props: Props) => {
const {boardTree, cardTree} = props
const {board} = boardTree
const {card} = cardTree
const {board, card, cards, views, activeView, contents, comments} = props
return (
<div className='octo-propertylist CardDetailProperties'>
{board.cardProperties.map((propertyTemplate) => {
const propertyValue = card.properties[propertyTemplate.id]
{board.fields.cardProperties.map((propertyTemplate: IPropertyTemplate) => {
const propertyValue = card.fields.properties[propertyTemplate.id]
return (
<div
key={propertyTemplate.id + '-' + propertyTemplate.type + '-' + propertyValue}
@ -41,16 +46,17 @@ const CardDetailProperties = React.memo((props: Props) => {
propertyId={propertyTemplate.id}
propertyName={propertyTemplate.name}
propertyType={propertyTemplate.type}
onTypeAndNameChanged={(newType: PropertyType, newName: string) => mutator.changePropertyTypeAndName(boardTree, propertyTemplate, newType, newName)}
onDelete={(id: string) => mutator.deleteProperty(boardTree, id)}
onTypeAndNameChanged={(newType: PropertyType, newName: string) => mutator.changePropertyTypeAndName(board, cards, propertyTemplate, newType, newName)}
onDelete={(id: string) => mutator.deleteProperty(board, views, cards, id)}
/>
</MenuWrapper>
}
<PropertyValueElement
readOnly={props.readonly}
card={card}
boardTree={boardTree}
cardTree={cardTree}
board={board}
contents={contents}
comments={comments}
propertyTemplate={propertyTemplate}
emptyDisplayValue='Empty'
/>
@ -63,7 +69,7 @@ const CardDetailProperties = React.memo((props: Props) => {
<Button
onClick={async () => {
// TODO: Show UI
await mutator.insertPropertyTemplate(boardTree)
await mutator.insertPropertyTemplate(board, activeView)
}}
>
<FormattedMessage

View File

@ -1,9 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {FC, useEffect, useState} from 'react'
import React, {FC} from 'react'
import {useIntl} from 'react-intl'
import {IBlock} from '../../blocks/block'
import {Block} from '../../blocks/block'
import mutator from '../../mutator'
import {Utils} from '../../utils'
import IconButton from '../../widgets/buttons/iconButton'
@ -11,12 +11,13 @@ import DeleteIcon from '../../widgets/icons/delete'
import OptionsIcon from '../../widgets/icons/options'
import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper'
import {UserCache} from '../../userCache'
import {getUser} from '../../store/users'
import {useAppSelector} from '../../store/hooks'
import './comment.scss'
type Props = {
comment: IBlock
comment: Block
userId: string
userImageUrl: string
}
@ -25,15 +26,7 @@ const Comment: FC<Props> = (props: Props) => {
const {comment, userId, userImageUrl} = props
const intl = useIntl()
const html = Utils.htmlFromMarkdown(comment.title)
const [username, setUsername] = useState('')
useEffect(() => {
UserCache.shared.getUser(userId).then((user) => {
if (user) {
setUsername(user.username)
}
})
}, [])
const user = useAppSelector(getUser(userId))
return (
<div
@ -45,7 +38,7 @@ const Comment: FC<Props> = (props: Props) => {
className='comment-avatar'
src={userImageUrl}
/>
<div className='comment-username'>{username}</div>
<div className='comment-username'>{user?.username}</div>
<div className='comment-date'>
{Utils.displayDateTime(new Date(comment.createAt), intl)}
</div>

View File

@ -3,7 +3,7 @@
import React, {useState} from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import {CommentBlock, MutableCommentBlock} from '../../blocks/commentBlock'
import {CommentBlock, createCommentBlock} from '../../blocks/commentBlock'
import mutator from '../../mutator'
import {Utils} from '../../utils'
import Button from '../../widgets/buttons/button'
@ -29,7 +29,7 @@ const CommentsList = React.memo((props: Props) => {
Utils.log(`Send comment: ${commentText}`)
Utils.assertValue(cardId)
const comment = new MutableCommentBlock()
const comment = createCommentBlock()
comment.parentId = cardId
comment.rootId = rootId
comment.title = commentText

View File

@ -1,22 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react'
import React from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import mutator from '../mutator'
import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree'
import {CardTree, CardTreeContext, MutableCardTree} from '../viewModel/cardTree'
import {BoardView} from '../blocks/boardView'
import {Board} from '../blocks/board'
import {Card} from '../blocks/card'
import DeleteIcon from '../widgets/icons/delete'
import Menu from '../widgets/menu'
import useCardListener from '../hooks/cardListener'
import {useAppSelector} from '../store/hooks'
import {getCard} from '../store/cards'
import {getCardContents} from '../store/contents'
import {getCardComments} from '../store/comments'
import CardDetail from './cardDetail/cardDetail'
import Dialog from './dialog'
type Props = {
boardTree: BoardTree
board: Board
activeView: BoardView
views: BoardView[]
cards: Card[]
cardId: string
onClose: () => void
showCard: (cardId?: string) => void
@ -24,39 +31,20 @@ type Props = {
}
const CardDialog = (props: Props) => {
const [syncComplete, setSyncComplete] = useState(false)
const [cardTree, setCardTree] = useState<CardTree>()
const {board, activeView, cards, views} = props
const card = useAppSelector(getCard(props.cardId))
const contents = useAppSelector(getCardContents(props.cardId))
const comments = useAppSelector(getCardComments(props.cardId))
const intl = useIntl()
useEffect(() => {
MutableCardTree.sync(props.cardId).then((ct) => {
setCardTree(ct)
setSyncComplete(true)
})
}, [props.boardTree])
useCardListener(
async (blocks) => {
Utils.log(`cardListener.onChanged: ${blocks.length}`)
const newCardTree = cardTree ? MutableCardTree.incrementalUpdate(cardTree, blocks) : await MutableCardTree.sync(props.cardId)
setCardTree(newCardTree)
setSyncComplete(true)
},
async () => {
Utils.log('cardListener.onReconnect')
const newCardTree = await MutableCardTree.sync(props.cardId)
setCardTree(newCardTree)
setSyncComplete(true)
},
)
const makeTemplateClicked = async () => {
if (!cardTree) {
Utils.assertFailure('cardTree')
if (!card) {
Utils.assertFailure('card')
return
}
await mutator.duplicateCard(
cardTree.card.id,
props.cardId,
intl.formatMessage({id: 'Mutator.new-template-from-card', defaultMessage: 'new template from card'}),
true,
async (newCardId) => {
@ -75,7 +63,6 @@ const CardDialog = (props: Props) => {
icon={<DeleteIcon/>}
name='Delete'
onClick={async () => {
const card = cardTree?.card
if (!card) {
Utils.assertFailure()
return
@ -84,7 +71,7 @@ const CardDialog = (props: Props) => {
props.onClose()
}}
/>
{(cardTree && !cardTree.card.isTemplate) &&
{(card && !card.fields.isTemplate) &&
<Menu.Text
id='makeTemplate'
name='New template from card'
@ -98,31 +85,33 @@ const CardDialog = (props: Props) => {
onClose={props.onClose}
toolsMenu={!props.readonly && menu}
>
{(cardTree?.card.isTemplate) &&
{card && card.fields.isTemplate &&
<div className='banner'>
<FormattedMessage
id='CardDialog.editing-template'
defaultMessage="You're editing a template"
/>
</div>
}
{cardTree &&
<CardTreeContext.Provider value={cardTree}>
</div>}
{card &&
<CardDetail
boardTree={props.boardTree}
cardTree={cardTree}
board={board}
activeView={activeView}
views={views}
cards={cards}
card={card}
contents={contents}
comments={comments}
readonly={props.readonly}
/>
</CardTreeContext.Provider>
}
{(!cardTree && syncComplete) &&
/>}
{!card &&
<div className='banner error'>
<FormattedMessage
id='CardDialog.nocard'
defaultMessage="This card doesn't exist or is inaccessible"
/>
</div>
}
</div>}
</Dialog>
)
}

View File

@ -3,15 +3,18 @@
/* eslint-disable max-lines */
import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {connect} from 'react-redux'
import Hotkeys from 'react-hot-keys'
import {BlockIcons} from '../blockIcons'
import {Card, MutableCard} from '../blocks/card'
import {Card, createCard} from '../blocks/card'
import {Board, IPropertyTemplate, IPropertyOption, BoardGroup} from '../blocks/board'
import {BoardView} from '../blocks/boardView'
import {CardFilter} from '../cardFilter'
import mutator from '../mutator'
import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree'
import {UserSettings} from '../userSettings'
import {addCard, addTemplate} from '../store/cards'
import './centerPanel.scss'
@ -25,10 +28,15 @@ import Table from './table/table'
import Gallery from './gallery/gallery'
type Props = {
boardTree: BoardTree
setSearchText: (text?: string) => void
board: Board
cards: Card[]
activeView: BoardView
views: BoardView[]
groupByProperty?: IPropertyTemplate
intl: IntlShape
readonly: boolean
addCard: (card: Card) => void
addTemplate: (template: Card) => void
}
type State = {
@ -94,16 +102,8 @@ class CenterPanel extends React.Component<Props, State> {
}
render(): JSX.Element {
const {boardTree} = this.props
const {groupByProperty} = boardTree
const {activeView} = boardTree
if (!groupByProperty && activeView.viewType === 'board') {
Utils.assertFailure('Board views must have groupByProperty set')
return <div/>
}
const {board} = boardTree
const {groupByProperty, activeView, board, views, cards} = this.props
const {visible: visibleGroups, hidden: hiddenGroups} = this.getVisibleAndHiddenGroups(cards, activeView.fields.visibleOptionIds, activeView.fields.hiddenOptionIds, groupByProperty)
return (
<div
@ -120,8 +120,11 @@ class CenterPanel extends React.Component<Props, State> {
{this.state.shownCardId &&
<RootPortal>
<CardDialog
board={board}
activeView={activeView}
views={views}
cards={cards}
key={this.state.shownCardId}
boardTree={boardTree}
cardId={this.state.shownCardId}
onClose={() => this.showCard(undefined)}
showCard={(cardId) => this.showCard(cardId)}
@ -137,8 +140,11 @@ class CenterPanel extends React.Component<Props, State> {
readonly={this.props.readonly}
/>
<ViewHeader
boardTree={boardTree}
setSearchText={this.props.setSearchText}
board={this.props.board}
activeView={this.props.activeView}
cards={this.props.cards}
views={this.props.views}
groupByProperty={this.props.groupByProperty}
addCard={() => this.addCard('', true)}
addCardFromTemplate={this.addCardFromTemplate}
addCardTemplate={this.addCardTemplate}
@ -147,9 +153,14 @@ class CenterPanel extends React.Component<Props, State> {
/>
</div>
{activeView.viewType === 'board' &&
{activeView.fields.viewType === 'board' &&
<Kanban
boardTree={boardTree}
board={this.props.board}
activeView={this.props.activeView}
cards={this.props.cards}
groupByProperty={this.props.groupByProperty}
visibleGroups={visibleGroups}
hiddenGroups={hiddenGroups}
selectedCardIds={this.state.selectedCardIds}
readonly={this.props.readonly}
onCardClicked={this.cardClicked}
@ -157,9 +168,14 @@ class CenterPanel extends React.Component<Props, State> {
showCard={this.showCard}
/>}
{activeView.viewType === 'table' &&
{activeView.fields.viewType === 'table' &&
<Table
boardTree={boardTree}
board={this.props.board}
activeView={this.props.activeView}
cards={this.props.cards}
groupByProperty={this.props.groupByProperty}
views={this.props.views}
visibleGroups={visibleGroups}
selectedCardIds={this.state.selectedCardIds}
readonly={this.props.readonly}
cardIdToFocusOnRender={this.state.cardIdToFocusOnRender}
@ -168,9 +184,11 @@ class CenterPanel extends React.Component<Props, State> {
onCardClicked={this.cardClicked}
/>}
{activeView.viewType === 'gallery' &&
{activeView.fields.viewType === 'gallery' &&
<Gallery
boardTree={boardTree}
board={this.props.board}
cards={this.props.cards}
activeView={this.props.activeView}
readonly={this.props.readonly}
onCardClicked={this.cardClicked}
selectedCardIds={this.state.selectedCardIds}
@ -203,30 +221,30 @@ class CenterPanel extends React.Component<Props, State> {
}
addCard = async (groupByOptionId?: string, show = false): Promise<void> => {
const {boardTree} = this.props
const {activeView, board} = boardTree
const {activeView, board, groupByProperty} = this.props
const card = new MutableCard()
const card = createCard()
card.parentId = boardTree.board.id
card.rootId = boardTree.board.rootId
const propertiesThatMeetFilters = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
if ((activeView.viewType === 'board' || activeView.viewType === 'table') && boardTree.groupByProperty) {
card.parentId = board.id
card.rootId = board.rootId
const propertiesThatMeetFilters = CardFilter.propertiesThatMeetFilterGroup(activeView.fields.filter, board.fields.cardProperties)
if ((activeView.fields.viewType === 'board' || activeView.fields.viewType === 'table') && groupByProperty) {
if (groupByOptionId) {
propertiesThatMeetFilters[boardTree.groupByProperty.id] = groupByOptionId
propertiesThatMeetFilters[groupByProperty.id] = groupByOptionId
} else {
delete propertiesThatMeetFilters[boardTree.groupByProperty.id]
delete propertiesThatMeetFilters[groupByProperty.id]
}
}
card.properties = {...card.properties, ...propertiesThatMeetFilters}
if (!card.icon && UserSettings.prefillRandomIcons) {
card.icon = BlockIcons.shared.randomIcon()
card.fields.properties = {...card.fields.properties, ...propertiesThatMeetFilters}
if (!card.fields.icon && UserSettings.prefillRandomIcons) {
card.fields.icon = BlockIcons.shared.randomIcon()
}
await mutator.insertBlock(
card,
'add card',
async () => {
if (show) {
this.props.addCard(card)
this.showCard(card.id)
} else {
// Focus on this card's title inline on next render
@ -241,16 +259,17 @@ class CenterPanel extends React.Component<Props, State> {
}
private addCardTemplate = async () => {
const {boardTree} = this.props
const {board} = this.props
const cardTemplate = new MutableCard()
cardTemplate.isTemplate = true
cardTemplate.parentId = boardTree.board.id
cardTemplate.rootId = boardTree.board.rootId
const cardTemplate = createCard()
cardTemplate.fields.isTemplate = true
cardTemplate.parentId = board.id
cardTemplate.rootId = board.rootId
await mutator.insertBlock(
cardTemplate,
'add card template',
async () => {
this.props.addTemplate(cardTemplate)
this.showCard(cardTemplate.id)
}, async () => {
this.showCard(undefined)
@ -263,14 +282,13 @@ class CenterPanel extends React.Component<Props, State> {
}
cardClicked = (e: React.MouseEvent, card: Card): void => {
const {boardTree} = this.props
const {activeView} = boardTree
const {activeView, cards} = this.props
if (e.shiftKey) {
let selectedCardIds = this.state.selectedCardIds.slice()
if (selectedCardIds.length > 0 && (e.metaKey || e.ctrlKey)) {
// Cmd+Shift+Click: Extend the selection
const orderedCardIds = boardTree.orderedCards().map((o) => o.id)
const orderedCardIds = cards.map((o) => o.id)
const lastCardId = selectedCardIds[selectedCardIds.length - 1]
const srcIndex = orderedCardIds.indexOf(lastCardId)
const destIndex = orderedCardIds.indexOf(card.id)
@ -290,7 +308,7 @@ class CenterPanel extends React.Component<Props, State> {
}
this.setState({selectedCardIds})
}
} else if (activeView.viewType === 'board' || activeView.viewType === 'gallery') {
} else if (activeView.fields.viewType === 'board' || activeView.fields.viewType === 'gallery') {
this.showCard(card.id)
}
@ -310,7 +328,7 @@ class CenterPanel extends React.Component<Props, State> {
mutator.performAsUndoGroup(async () => {
for (const cardId of selectedCardIds) {
const card = this.props.boardTree.allCards.find((o) => o.id === cardId)
const card = this.props.cards.find((o) => o.id === cardId)
if (card) {
mutator.deleteBlock(card, selectedCardIds.length > 1 ? `delete ${selectedCardIds.length} cards` : 'delete card')
} else {
@ -330,7 +348,7 @@ class CenterPanel extends React.Component<Props, State> {
mutator.performAsUndoGroup(async () => {
for (const cardId of selectedCardIds) {
const card = this.props.boardTree.allCards.find((o) => o.id === cardId)
const card = this.props.cards.find((o) => o.id === cardId)
if (card) {
mutator.duplicateCard(cardId)
} else {
@ -341,6 +359,55 @@ class CenterPanel extends React.Component<Props, State> {
this.setState({selectedCardIds: []})
}
private groupCardsByOptions(cards: Card[], optionIds: string[], groupByProperty?: IPropertyTemplate): BoardGroup[] {
const groups = []
for (const optionId of optionIds) {
if (optionId) {
const option = groupByProperty?.options.find((o) => o.id === optionId)
if (option) {
const c = cards.filter((o) => optionId === o.fields.properties[groupByProperty!.id])
const group: BoardGroup = {
option,
cards: c,
}
groups.push(group)
} else {
Utils.logError(`groupCardsByOptions: Missing option with id: ${optionId}`)
}
} else {
// Empty group
const emptyGroupCards = cards.filter((card) => {
const groupByOptionId = card.fields.properties[groupByProperty?.id || '']
return !groupByOptionId || !groupByProperty?.options.find((option) => option.id === groupByOptionId)
})
const group: BoardGroup = {
option: {id: '', value: `No ${groupByProperty?.name}`, color: ''},
cards: emptyGroupCards,
}
groups.push(group)
}
}
return groups
}
export default injectIntl(CenterPanel)
private getVisibleAndHiddenGroups(cards: Card[], visibleOptionIds: string[], hiddenOptionIds: string[], groupByProperty?: IPropertyTemplate): {visible: BoardGroup[], hidden: BoardGroup[]} {
let unassignedOptionIds: string[] = []
if (groupByProperty) {
unassignedOptionIds = groupByProperty.options.
filter((o: IPropertyOption) => !visibleOptionIds.includes(o.id) && !hiddenOptionIds.includes(o.id)).
map((o: IPropertyOption) => o.id)
}
const allVisibleOptionIds = [...visibleOptionIds, ...unassignedOptionIds]
// If the empty group positon is not explicitly specified, make it the first visible column
if (!allVisibleOptionIds.includes('') && !hiddenOptionIds.includes('')) {
allVisibleOptionIds.unshift('')
}
const visibleGroups = this.groupCardsByOptions(cards, allVisibleOptionIds, groupByProperty)
const hiddenGroups = this.groupCardsByOptions(cards, hiddenOptionIds, groupByProperty)
return {visible: visibleGroups, hidden: hiddenGroups}
}
}
export default connect(undefined, {addCard, addTemplate})(injectIntl(CenterPanel))

View File

@ -6,7 +6,7 @@ import {fireEvent, render} from '@testing-library/react'
import '@testing-library/jest-dom'
import {IntlProvider} from 'react-intl'
import {IContentBlock} from '../../blocks/contentBlock'
import {ContentBlock} from '../../blocks/contentBlock'
import CheckboxElement from './checkboxElement'
@ -15,7 +15,7 @@ const fetchMock = require('fetch-mock-jest')
const wrapIntl = (children: any) => <IntlProvider locale='en'>{children}</IntlProvider>
describe('components/content/CheckboxElement', () => {
const defaultBlock: IContentBlock = {
const defaultBlock: ContentBlock = {
id: 'test-id',
parentId: '',
rootId: '',

View File

@ -3,8 +3,8 @@
import React, {useState} from 'react'
import {useIntl} from 'react-intl'
import {MutableCheckboxBlock} from '../../blocks/checkboxBlock'
import {IContentBlock} from '../../blocks/contentBlock'
import {createCheckboxBlock} from '../../blocks/checkboxBlock'
import {ContentBlock} from '../../blocks/contentBlock'
import CheckIcon from '../../widgets/icons/check'
import mutator from '../../mutator'
import Editable from '../../widgets/editable'
@ -13,7 +13,7 @@ import {contentRegistry} from './contentRegistry'
import './checkboxElement.scss'
type Props = {
block: IContentBlock
block: ContentBlock
readonly: boolean
}
@ -34,7 +34,7 @@ const CheckboxElement = React.memo((props: Props) => {
value={active ? 'on' : 'off'}
onChange={(e) => {
e.preventDefault()
const newBlock = new MutableCheckboxBlock(block)
const newBlock = createCheckboxBlock(block)
newBlock.fields.value = !active
newBlock.title = title
setActive(newBlock.fields.value)
@ -46,7 +46,7 @@ const CheckboxElement = React.memo((props: Props) => {
placeholderText={intl.formatMessage({id: 'ContentBlock.editText', defaultMessage: 'Edit text...'})}
onChange={setTitle}
onSave={() => {
const newBlock = new MutableCheckboxBlock(block)
const newBlock = createCheckboxBlock(block)
newBlock.title = title
newBlock.fields.value = active
mutator.updateBlock(newBlock, block, intl.formatMessage({id: 'ContentBlock.editCardCheckboxText', defaultMessage: 'edit card text'}))
@ -63,7 +63,7 @@ contentRegistry.registerContentType({
getDisplayText: (intl) => intl.formatMessage({id: 'ContentBlock.checkbox', defaultMessage: 'checkbox'}),
getIcon: () => <CheckIcon/>,
createBlock: async () => {
return new MutableCheckboxBlock()
return createCheckboxBlock()
},
createComponent: (block, readonly) => {
return (

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IContentBlock} from '../../blocks/contentBlock'
import {ContentBlock} from '../../blocks/contentBlock'
import {Utils} from '../../utils'
import {contentRegistry} from './contentRegistry'
@ -14,7 +14,7 @@ import './dividerElement'
import './checkboxElement'
type Props = {
block: IContentBlock
block: ContentBlock
readonly: boolean
}

View File

@ -4,15 +4,15 @@
import {IntlShape} from 'react-intl'
import {BlockTypes} from '../../blocks/block'
import {IContentBlock, MutableContentBlock} from '../../blocks/contentBlock'
import {ContentBlock} from '../../blocks/contentBlock'
import {Utils} from '../../utils'
type ContentHandler = {
type: BlockTypes,
getDisplayText: (intl: IntlShape) => string,
getIcon: () => JSX.Element,
createBlock: (rootId: string) => Promise<MutableContentBlock>,
createComponent: (block: IContentBlock, readonly: boolean) => JSX.Element,
createBlock: (rootId: string) => Promise<ContentBlock>,
createComponent: (block: ContentBlock, readonly: boolean) => JSX.Element,
}
class ContentRegistry {

View File

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react'
import {MutableDividerBlock} from '../../blocks/dividerBlock'
import {DividerBlock, createDividerBlock} from '../../blocks/dividerBlock'
import DividerIcon from '../../widgets/icons/divider'
import {contentRegistry} from './contentRegistry'
@ -14,8 +14,8 @@ contentRegistry.registerContentType({
type: 'divider',
getDisplayText: (intl) => intl.formatMessage({id: 'ContentBlock.divider', defaultMessage: 'divider'}),
getIcon: () => <DividerIcon/>,
createBlock: async () => {
return new MutableDividerBlock()
createBlock: async (): Promise<DividerBlock> => {
return createDividerBlock()
},
createComponent: () => <DividerElement/>,
})

View File

@ -2,8 +2,8 @@
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react'
import {IContentBlock} from '../../blocks/contentBlock'
import {MutableImageBlock} from '../../blocks/imageBlock'
import {ContentBlock} from '../../blocks/contentBlock'
import {ImageBlock, createImageBlock} from '../../blocks/imageBlock'
import octoClient from '../../octoClient'
import {Utils} from '../../utils'
import ImageIcon from '../../widgets/icons/image'
@ -11,7 +11,7 @@ import ImageIcon from '../../widgets/icons/image'
import {contentRegistry} from './contentRegistry'
type Props = {
block: IContentBlock
block: ContentBlock
}
const ImageElement = React.memo((props: Props): JSX.Element|null => {
@ -47,20 +47,20 @@ contentRegistry.registerContentType({
getDisplayText: (intl) => intl.formatMessage({id: 'ContentBlock.image', defaultMessage: 'image'}),
getIcon: () => <ImageIcon/>,
createBlock: async (rootId: string) => {
return new Promise<MutableImageBlock>(
return new Promise<ImageBlock>(
(resolve) => {
Utils.selectLocalFile(async (file) => {
const fileId = await octoClient.uploadFile(rootId, file)
const block = new MutableImageBlock()
block.fileId = fileId || ''
const block = createImageBlock()
block.fields.fileId = fileId || ''
resolve(block)
},
'.jpg,.jpeg,.png')
},
)
// return new MutableImageBlock()
// return new ImageBlock()
},
createComponent: (block) => <ImageElement block={block}/>,
})

View File

@ -3,8 +3,8 @@
import React from 'react'
import {useIntl} from 'react-intl'
import {IContentBlock} from '../../blocks/contentBlock'
import {MutableTextBlock} from '../../blocks/textBlock'
import {ContentBlock} from '../../blocks/contentBlock'
import {createTextBlock} from '../../blocks/textBlock'
import mutator from '../../mutator'
import TextIcon from '../../widgets/icons/text'
import {MarkdownEditor} from '../markdownEditor'
@ -12,7 +12,7 @@ import {MarkdownEditor} from '../markdownEditor'
import {contentRegistry} from './contentRegistry'
type Props = {
block: IContentBlock
block: ContentBlock
readonly: boolean
}
@ -37,7 +37,7 @@ contentRegistry.registerContentType({
getDisplayText: (intl) => intl.formatMessage({id: 'ContentBlock.text', defaultMessage: 'text'}),
getIcon: () => <TextIcon/>,
createBlock: async () => {
return new MutableTextBlock()
return createTextBlock()
},
createComponent: (block, readonly) => {
return (

View File

@ -5,7 +5,7 @@ import React from 'react'
import {useIntl} from 'react-intl'
import {Card} from '../blocks/card'
import {IContentBlock, IContentBlockWithCords} from '../blocks/contentBlock'
import {ContentBlock as ContentBlockType, IContentBlockWithCords} from '../blocks/contentBlock'
import mutator from '../mutator'
import {Utils} from '../utils'
import IconButton from '../widgets/buttons/iconButton'
@ -26,7 +26,7 @@ import {contentRegistry} from './content/contentRegistry'
import './contentBlock.scss'
type Props = {
block: IContentBlock
block: ContentBlockType
card: Card
readonly: boolean
onDrop: (srctBlock: IContentBlockWithCords, dstBlock: IContentBlockWithCords, position: Position) => void
@ -43,7 +43,7 @@ const ContentBlock = React.memo((props: Props): JSX.Element => {
const index = cords.x
const colIndex = (cords.y || cords.y === 0) && cords.y > -1 ? cords.y : -1
const contentOrder = card.contentOrder.slice()
const contentOrder = card.fields.contentOrder.slice()
const className = 'ContentBlock octo-block'
return (

View File

@ -3,15 +3,12 @@
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {IWorkspace} from '../blocks/workspace'
import {getWorkspace} from '../store/workspace'
import {useAppSelector} from '../store/hooks'
import './emptyCenterPanel.scss'
type Props = {
workspace?: IWorkspace
}
const EmptyCenterPanel = React.memo((props: Props) => {
const {workspace} = props
const EmptyCenterPanel = React.memo(() => {
const workspace = useAppSelector(getWorkspace)
return (
<div className='EmptyCenterPanel'>

View File

@ -1,21 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react'
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {Constants} from '../../constants'
import {Card} from '../../blocks/card'
import {Board, IPropertyTemplate} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import mutator from '../../mutator'
import {Utils} from '../../utils'
import {BoardTree} from '../../viewModel/boardTree'
import {CardTree, MutableCardTree} from '../../viewModel/cardTree'
import useCardListener from '../../hooks/cardListener'
import './gallery.scss'
import GalleryCard from './galleryCard'
type Props = {
boardTree: BoardTree
board: Board
cards: Card[]
activeView: BoardView
readonly: boolean
addCard: (show: boolean) => Promise<void>
selectedCardIds: string[]
@ -23,11 +24,9 @@ type Props = {
}
const Gallery = (props: Props): JSX.Element => {
const {boardTree} = props
const {cards, activeView} = boardTree
const visiblePropertyTemplates = boardTree.board.cardProperties.filter((template) => boardTree.activeView.visiblePropertyIds.includes(template.id))
const [cardTrees, setCardTrees] = useState<{[key: string]: CardTree | undefined}>({})
const isManualSort = activeView.sortOptions.length === 0
const {activeView, board, cards} = props
const visiblePropertyTemplates = board.fields.cardProperties.filter((template: IPropertyTemplate) => activeView.fields.visiblePropertyIds.includes(template.id))
const isManualSort = activeView.fields.sortOptions.length === 0
const onDropToCard = (srcCard: Card, dstCard: Card) => {
Utils.log(`onDropToCard: ${dstCard.title}`)
@ -37,7 +36,7 @@ const Gallery = (props: Props): JSX.Element => {
const description = draggedCardIds.length > 1 ? `drag ${draggedCardIds.length} cards` : 'drag card'
// Update dstCard order
let cardOrder = Array.from(new Set([...activeView.cardOrder, ...boardTree.cards.map((o) => o.id)]))
let cardOrder = Array.from(new Set([...activeView.fields.cardOrder, ...cards.map((o) => o.id)]))
const isDraggingDown = cardOrder.indexOf(srcCard.id) <= cardOrder.indexOf(dstCard.id)
cardOrder = cardOrder.filter((id) => !draggedCardIds.includes(id))
let destIndex = cardOrder.indexOf(dstCard.id)
@ -51,40 +50,16 @@ const Gallery = (props: Props): JSX.Element => {
})
}
const visibleTitle = boardTree.activeView.visiblePropertyIds.includes(Constants.titleColumnId)
useCardListener(
async (blocks) => {
cards.forEach(async (c) => {
const cardTree = cardTrees[c.id]
const newCardTree = cardTree ? MutableCardTree.incrementalUpdate(cardTree, blocks) : await MutableCardTree.sync(c.id)
setCardTrees((oldTree) => ({...oldTree, [c.id]: newCardTree}))
})
},
async () => {
cards.forEach(async (c) => {
const newCardTree = await MutableCardTree.sync(c.id)
setCardTrees((oldTree) => ({...oldTree, [c.id]: newCardTree}))
})
},
)
useEffect(() => {
cards.forEach(async (c) => {
const newCardTree = await MutableCardTree.sync(c.id)
setCardTrees((oldTree) => ({...oldTree, [c.id]: newCardTree}))
})
}, [cards])
const visibleTitle = activeView.fields.visiblePropertyIds.includes(Constants.titleColumnId)
return (
<div className='octo-table-body Gallery'>
{cards.map((card) => {
const cardTree = cardTrees[card.id]
if (cardTree) {
{cards.filter((c) => c.parentId === board.id).map((card) => {
return (
<GalleryCard
key={card.id + card.updateAt}
cardTree={cardTree}
card={card}
board={board}
onClick={props.onCardClicked}
visiblePropertyTemplates={visiblePropertyTemplates}
visibleTitle={visibleTitle}
@ -94,7 +69,6 @@ const Gallery = (props: Props): JSX.Element => {
isManualSort={isManualSort}
/>
)
}
return null
})}

View File

@ -3,10 +3,9 @@
import React from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import {IPropertyTemplate} from '../../blocks/board'
import {Board, IPropertyTemplate} from '../../blocks/board'
import {Card} from '../../blocks/card'
import {CardTree} from '../../viewModel/cardTree'
import {IContentBlock} from '../../blocks/contentBlock'
import {ContentBlock} from '../../blocks/contentBlock'
import mutator from '../../mutator'
import IconButton from '../../widgets/buttons/iconButton'
@ -21,11 +20,15 @@ import ImageElement from '../content/imageElement'
import ContentElement from '../content/contentElement'
import PropertyValueElement from '../propertyValueElement'
import Tooltip from '../../widgets/tooltip'
import {useAppSelector} from '../../store/hooks'
import {getCardContents} from '../../store/contents'
import {getCardComments} from '../../store/comments'
import './galleryCard.scss'
type Props = {
cardTree: CardTree
board: Board
card: Card
onClick: (e: React.MouseEvent, card: Card) => void
visiblePropertyTemplates: IPropertyTemplate[]
visibleTitle: boolean
@ -36,18 +39,20 @@ type Props = {
}
const GalleryCard = React.memo((props: Props) => {
const {cardTree} = props
const {card, board} = props
const intl = useIntl()
const [isDragging, isOver, cardRef] = useSortable('card', cardTree.card, props.isManualSort && !props.readonly, props.onDrop)
const [isDragging, isOver, cardRef] = useSortable('card', card, props.isManualSort && !props.readonly, props.onDrop)
const contents = useAppSelector(getCardContents(card.id))
const comments = useAppSelector(getCardComments(card.id))
const visiblePropertyTemplates = props.visiblePropertyTemplates || []
let image: IContentBlock | undefined
for (let i = 0; i < cardTree.contents.length; ++i) {
if (Array.isArray(cardTree.contents[i])) {
image = (cardTree.contents[i] as IContentBlock[]).find((c) => c.type === 'image')
} else if ((cardTree.contents[i] as IContentBlock).type === 'image') {
image = cardTree.contents[i] as IContentBlock
let image: ContentBlock | undefined
for (let i = 0; i < contents.length; ++i) {
if (Array.isArray(contents[i])) {
image = (contents[i] as ContentBlock[]).find((c) => c.type === 'image')
} else if ((contents[i] as ContentBlock).type === 'image') {
image = contents[i] as ContentBlock
}
if (image) {
@ -63,7 +68,7 @@ const GalleryCard = React.memo((props: Props) => {
return (
<div
className={className}
onClick={(e: React.MouseEvent) => props.onClick(e, cardTree.card)}
onClick={(e: React.MouseEvent) => props.onClick(e, card)}
style={{opacity: isDragging ? 0.5 : 1}}
ref={cardRef}
>
@ -78,14 +83,14 @@ const GalleryCard = React.memo((props: Props) => {
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'GalleryCard.delete', defaultMessage: 'Delete'})}
onClick={() => mutator.deleteBlock(cardTree.card, 'delete card')}
onClick={() => mutator.deleteBlock(card, 'delete card')}
/>
<Menu.Text
icon={<DuplicateIcon/>}
id='duplicate'
name={intl.formatMessage({id: 'GalleryCard.duplicate', defaultMessage: 'Duplicate'})}
onClick={() => {
mutator.duplicateCard(cardTree.card.id)
mutator.duplicateCard(card.id)
}}
/>
</Menu>
@ -98,7 +103,7 @@ const GalleryCard = React.memo((props: Props) => {
</div>}
{!image &&
<div className='gallery-item'>
{cardTree?.contents.map((block) => {
{contents.map((block) => {
if (Array.isArray(block)) {
return block.map((b) => (
<ContentElement
@ -120,9 +125,9 @@ const GalleryCard = React.memo((props: Props) => {
</div>}
{props.visibleTitle &&
<div className='gallery-title'>
{ cardTree.card.icon ? <div className='octo-icon'>{cardTree.card.icon}</div> : undefined }
{ card.fields.icon ? <div className='octo-icon'>{card.fields.icon}</div> : undefined }
<div key='__title'>
{cardTree.card.title ||
{card.title ||
<FormattedMessage
id='KanbanCard.untitled'
defaultMessage='Untitled'
@ -138,9 +143,11 @@ const GalleryCard = React.memo((props: Props) => {
placement='top'
>
<PropertyValueElement
contents={contents}
comments={comments}
board={board}
readOnly={true}
card={cardTree.card}
cardTree={cardTree}
card={card}
propertyTemplate={template}
emptyDisplayValue=''
/>

View File

@ -1,20 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import React, {useRef, useState} from 'react'
import React, {useCallback} from 'react'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {IPropertyOption} from '../../blocks/board'
import {Board, IPropertyOption, IPropertyTemplate, BoardGroup} from '../../blocks/board'
import {Card} from '../../blocks/card'
import {BoardView} from '../../blocks/boardView'
import mutator from '../../mutator'
import {Utils} from '../../utils'
import {BoardTree} from '../../viewModel/boardTree'
import Button from '../../widgets/buttons/button'
import {CardTree, MutableCardTree} from '../../viewModel/cardTree'
import useCardListener from '../../hooks/cardListener'
import KanbanCard from './kanbanCard'
import KanbanColumn from './kanbanColumn'
import KanbanColumnHeader from './kanbanColumnHeader'
@ -23,7 +19,12 @@ import KanbanHiddenColumnItem from './kanbanHiddenColumnItem'
import './kanban.scss'
type Props = {
boardTree: BoardTree
board: Board
activeView: BoardView
cards: Card[]
groupByProperty?: IPropertyTemplate
visibleGroups: BoardGroup[]
hiddenGroups: BoardGroup[]
selectedCardIds: string[]
intl: IntlShape
readonly: boolean
@ -33,8 +34,7 @@ type Props = {
}
const Kanban = (props: Props) => {
const {boardTree} = props
const {cards, groupByProperty} = boardTree
const {board, activeView, cards, groupByProperty, visibleGroups, hiddenGroups} = props
if (!groupByProperty) {
Utils.assertFailure('Board views must have groupByProperty set')
@ -44,36 +44,14 @@ const Kanban = (props: Props) => {
const propertyValues = groupByProperty.options || []
Utils.log(`${propertyValues.length} propertyValues`)
const {board, activeView, visibleGroups, hiddenGroups} = boardTree
const visiblePropertyTemplates = board.cardProperties.filter((template) => activeView.visiblePropertyIds.includes(template.id))
const isManualSort = activeView.sortOptions.length === 0
const visiblePropertyTemplates = board.fields.cardProperties.filter((template: IPropertyTemplate) => activeView.fields.visiblePropertyIds.includes(template.id))
const isManualSort = activeView.fields.sortOptions.length === 0
const [cardTrees, setCardTrees] = useState<{[key: string]: CardTree | undefined}>({})
const cardTreeRef = useRef<{[key: string]: CardTree | undefined}>()
cardTreeRef.current = cardTrees
const propertyNameChanged = useCallback(async (option: IPropertyOption, text: string): Promise<void> => {
await mutator.changePropertyOptionValue(board, groupByProperty!, option, text)
}, [board, groupByProperty])
useCardListener(
async (blocks) => {
for (const block of blocks) {
const cardTree = cardTreeRef.current && cardTreeRef.current[block.parentId]
// eslint-disable-next-line no-await-in-loop
const newCardTree = cardTree ? MutableCardTree.incrementalUpdate(cardTree, blocks) : await MutableCardTree.sync(block.parentId)
setCardTrees((oldTree) => ({...oldTree, [block.parentId]: newCardTree}))
}
},
async () => {
cards.forEach(async (c) => {
const newCardTree = await MutableCardTree.sync(c.id)
setCardTrees((oldTree) => ({...oldTree, [c.id]: newCardTree}))
})
},
)
const propertyNameChanged = async (option: IPropertyOption, text: string): Promise<void> => {
await mutator.changePropertyOptionValue(boardTree, boardTree.groupByProperty!, option, text)
}
const addGroupClicked = async () => {
const addGroupClicked = useCallback(async () => {
Utils.log('onAddGroupClicked')
const option: IPropertyOption = {
@ -82,12 +60,12 @@ const Kanban = (props: Props) => {
color: 'propColorDefault',
}
await mutator.insertPropertyOption(boardTree, boardTree.groupByProperty!, option, 'add group')
}
await mutator.insertPropertyOption(board, groupByProperty!, option, 'add group')
}, [board, groupByProperty])
const orderAfterMoveToColumn = (cardIds: string[], columnId?: string): string[] => {
let cardOrder = activeView.cardOrder.slice()
const columnGroup = boardTree.visibleGroups.find((g) => g.option.id === columnId)
const orderAfterMoveToColumn = useCallback((cardIds: string[], columnId?: string): string[] => {
let cardOrder = activeView.fields.cardOrder.slice()
const columnGroup = visibleGroups.find((g) => g.option.id === columnId)
const columnCards = columnGroup?.cards
if (!columnCards || columnCards.length === 0) {
return cardOrder
@ -98,9 +76,9 @@ const Kanban = (props: Props) => {
const lastCardIndex = cardOrder.indexOf(lastCardId)
cardOrder.splice(lastCardIndex + 1, 0, ...cardIds)
return cardOrder
}
}, [activeView, visibleGroups])
const onDropToColumn = async (option: IPropertyOption, card?: Card, dstOption?: IPropertyOption) => {
const onDropToColumn = useCallback(async (option: IPropertyOption, card?: Card, dstOption?: IPropertyOption) => {
const {selectedCardIds} = props
const optionId = option ? option.id : undefined
@ -109,34 +87,31 @@ const Kanban = (props: Props) => {
draggedCardIds = Array.from(new Set(selectedCardIds).add(card.id))
}
Utils.assertValue(boardTree)
if (draggedCardIds.length > 0) {
const orderedCards = boardTree.orderedCards()
const cardsById: { [key: string]: Card } = orderedCards.reduce((acc: { [key: string]: Card }, c: Card): { [key: string]: Card } => {
await mutator.performAsUndoGroup(async () => {
const cardsById: { [key: string]: Card } = cards.reduce((acc: { [key: string]: Card }, c: Card): { [key: string]: Card } => {
acc[c.id] = c
return acc
}, {})
const draggedCards: Card[] = draggedCardIds.map((o: string) => cardsById[o])
await mutator.performAsUndoGroup(async () => {
const draggedCards: Card[] = draggedCardIds.map((o: string) => cardsById[o]).filter((c) => c)
const description = draggedCards.length > 1 ? `drag ${draggedCards.length} cards` : 'drag card'
const awaits = []
for (const draggedCard of draggedCards) {
Utils.log(`ondrop. Card: ${draggedCard.title}, column: ${optionId}`)
const oldValue = draggedCard.properties[boardTree.groupByProperty!.id]
const oldValue = draggedCard.fields.properties[groupByProperty!.id]
if (optionId !== oldValue) {
awaits.push(mutator.changePropertyValue(draggedCard, boardTree.groupByProperty!.id, optionId, description))
awaits.push(mutator.changePropertyValue(draggedCard, groupByProperty!.id, optionId, description))
}
}
const newOrder = orderAfterMoveToColumn(draggedCardIds, optionId)
awaits.push(mutator.changeViewCardOrder(boardTree.activeView, newOrder, description))
awaits.push(mutator.changeViewCardOrder(activeView, newOrder, description))
await Promise.all(awaits)
})
} else if (dstOption) {
Utils.log(`ondrop. Header option: ${dstOption.value}, column: ${option?.value}`)
// Move option to new index
const visibleOptionIds = boardTree.visibleGroups.map((o) => o.option.id)
const visibleOptionIds = visibleGroups.map((o) => o.option.id)
const srcIndex = visibleOptionIds.indexOf(dstOption.id)
const destIndex = visibleOptionIds.indexOf(option.id)
@ -146,32 +121,31 @@ const Kanban = (props: Props) => {
await mutator.changeViewVisibleOptionIds(activeView, visibleOptionIds)
}
}
}, [cards, visibleGroups, activeView, groupByProperty, props.selectedCardIds])
const onDropToCard = async (srcCard: Card, dstCard: Card) => {
const onDropToCard = useCallback(async (srcCard: Card, dstCard: Card) => {
if (srcCard.id === dstCard.id) {
return
}
Utils.log(`onDropToCard: ${dstCard.title}`)
const {selectedCardIds} = props
const optionId = dstCard.properties[activeView.groupById!]
const optionId = dstCard.fields.properties[groupByProperty.id]
const draggedCardIds = Array.from(new Set(selectedCardIds).add(srcCard.id))
const description = draggedCardIds.length > 1 ? `drag ${draggedCardIds.length} cards` : 'drag card'
// Update dstCard order
const orderedCards = boardTree.orderedCards()
const cardsById: { [key: string]: Card } = orderedCards.reduce((acc: { [key: string]: Card }, card: Card): { [key: string]: Card } => {
const cardsById: { [key: string]: Card } = cards.reduce((acc: { [key: string]: Card }, card: Card): { [key: string]: Card } => {
acc[card.id] = card
return acc
}, {})
const draggedCards: Card[] = draggedCardIds.map((o: string) => cardsById[o])
let cardOrder = orderedCards.map((o) => o.id)
const draggedCards: Card[] = draggedCardIds.map((o: string) => cardsById[o]).filter((c) => c)
let cardOrder = cards.map((o) => o.id)
const isDraggingDown = cardOrder.indexOf(srcCard.id) <= cardOrder.indexOf(dstCard.id)
cardOrder = cardOrder.filter((id) => !draggedCardIds.includes(id))
let destIndex = cardOrder.indexOf(dstCard.id)
if (srcCard.properties[boardTree.groupByProperty!.id] === optionId && isDraggingDown) {
if (srcCard.fields.properties[groupByProperty!.id] === optionId && isDraggingDown) {
// If the cards are in the same column and dragging down, drop after the target dstCard
destIndex += 1
}
@ -182,15 +156,15 @@ const Kanban = (props: Props) => {
const awaits = []
for (const draggedCard of draggedCards) {
Utils.log(`draggedCard: ${draggedCard.title}, column: ${optionId}`)
const oldOptionId = draggedCard.properties[boardTree.groupByProperty!.id]
const oldOptionId = draggedCard.fields.properties[groupByProperty!.id]
if (optionId !== oldOptionId) {
awaits.push(mutator.changePropertyValue(draggedCard, boardTree.groupByProperty!.id, optionId, description))
awaits.push(mutator.changePropertyValue(draggedCard, groupByProperty!.id, optionId, description))
}
}
await Promise.all(awaits)
await mutator.changeViewCardOrder(activeView, cardOrder, description)
})
}
}, [cards, activeView, groupByProperty, props.selectedCardIds])
return (
<div className='Kanban'>
@ -204,8 +178,10 @@ const Kanban = (props: Props) => {
<KanbanColumnHeader
key={group.option.id}
group={group}
boardTree={boardTree}
board={board}
activeView={activeView}
intl={props.intl}
groupByProperty={groupByProperty}
addCard={props.addCard}
readonly={props.readonly}
propertyNameChanged={propertyNameChanged}
@ -254,7 +230,7 @@ const Kanban = (props: Props) => {
{group.cards.map((card) => (
<KanbanCard
card={card}
cardTree={cardTrees[card.id]}
board={board}
visiblePropertyTemplates={visiblePropertyTemplates}
key={card.id}
readonly={props.readonly}
@ -290,7 +266,7 @@ const Kanban = (props: Props) => {
<KanbanHiddenColumnItem
key={group.option.id}
group={group}
boardTree={boardTree}
activeView={activeView}
intl={props.intl}
readonly={props.readonly}
onDrop={(card: Card) => onDropToColumn(group.option, card)}

View File

@ -3,7 +3,7 @@
import React from 'react'
import {useIntl} from 'react-intl'
import {IPropertyTemplate} from '../../blocks/board'
import {Board, IPropertyTemplate} from '../../blocks/board'
import {Card} from '../../blocks/card'
import mutator from '../../mutator'
import IconButton from '../../widgets/buttons/iconButton'
@ -13,15 +13,17 @@ import OptionsIcon from '../../widgets/icons/options'
import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper'
import {useSortable} from '../../hooks/sortable'
import {useAppSelector} from '../../store/hooks'
import {getCardContents} from '../../store/contents'
import {getCardComments} from '../../store/comments'
import './kanbanCard.scss'
import PropertyValueElement from '../propertyValueElement'
import {CardTree} from '../../viewModel/cardTree'
import Tooltip from '../../widgets/tooltip'
type Props = {
card: Card
cardTree?: CardTree
board: Board
visiblePropertyTemplates: IPropertyTemplate[]
isSelected: boolean
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
@ -32,7 +34,7 @@ type Props = {
}
const KanbanCard = React.memo((props: Props) => {
const {card} = props
const {card, board} = props
const intl = useIntl()
const [isDragging, isOver, cardRef] = useSortable('card', card, !props.readonly, props.onDrop)
const visiblePropertyTemplates = props.visiblePropertyTemplates || []
@ -41,6 +43,9 @@ const KanbanCard = React.memo((props: Props) => {
className += ' dragover'
}
const contents = useAppSelector(getCardContents(card.id))
const comments = useAppSelector(getCardComments(card.id))
return (
<div
ref={props.readonly ? () => null : cardRef}
@ -85,7 +90,7 @@ const KanbanCard = React.memo((props: Props) => {
}
<div className='octo-icontitle'>
{ card.icon ? <div className='octo-icon'>{card.icon}</div> : undefined }
{ card.fields.icon ? <div className='octo-icon'>{card.fields.icon}</div> : undefined }
<div key='__title'>{card.title || intl.formatMessage({id: 'KanbanCard.untitled', defaultMessage: 'Untitled'})}</div>
</div>
{visiblePropertyTemplates.map((template) => (
@ -94,9 +99,11 @@ const KanbanCard = React.memo((props: Props) => {
title={template.name}
>
<PropertyValueElement
board={board}
readOnly={true}
card={card}
cardTree={props.cardTree}
contents={contents}
comments={comments}
propertyTemplate={template}
emptyDisplayValue=''
/>

View File

@ -6,10 +6,10 @@ import {FormattedMessage, IntlShape} from 'react-intl'
import {useDrop, useDrag} from 'react-dnd'
import {Constants} from '../../constants'
import {IPropertyOption} from '../../blocks/board'
import {IPropertyOption, IPropertyTemplate, Board, BoardGroup} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import {Card} from '../../blocks/card'
import mutator from '../../mutator'
import {BoardTree, BoardTreeGroup} from '../../viewModel/boardTree'
import Button from '../../widgets/buttons/button'
import IconButton from '../../widgets/buttons/iconButton'
import AddIcon from '../../widgets/icons/add'
@ -22,8 +22,10 @@ import Editable from '../../widgets/editable'
import Label from '../../widgets/label'
type Props = {
boardTree: BoardTree
group: BoardTreeGroup
board: Board
activeView: BoardView
group: BoardGroup
groupByProperty?: IPropertyTemplate
intl: IntlShape
readonly: boolean
addCard: (groupByOptionId?: string) => Promise<void>
@ -32,8 +34,7 @@ type Props = {
}
export default function KanbanColumnHeader(props: Props): JSX.Element {
const {boardTree, intl, group} = props
const {activeView} = boardTree
const {board, activeView, intl, group, groupByProperty} = props
const [groupTitle, setGroupTitle] = useState(group.option.value)
const headerRef = useRef<HTMLDivElement>(null)
@ -78,13 +79,13 @@ export default function KanbanColumnHeader(props: Props): JSX.Element {
title={intl.formatMessage({
id: 'BoardComponent.no-property-title',
defaultMessage: 'Items with an empty {property} property will go here. This column cannot be removed.',
}, {property: boardTree.groupByProperty!.name})}
}, {property: groupByProperty!.name})}
>
<FormattedMessage
id='BoardComponent.no-property'
defaultMessage='No {property}'
values={{
property: boardTree.groupByProperty!.name,
property: groupByProperty!.name,
}}
/>
</Label>}
@ -126,7 +127,7 @@ export default function KanbanColumnHeader(props: Props): JSX.Element {
id='delete'
icon={<DeleteIcon/>}
name={intl.formatMessage({id: 'BoardComponent.delete', defaultMessage: 'Delete'})}
onClick={() => mutator.deletePropertyOption(boardTree, boardTree.groupByProperty!, group.option)}
onClick={() => mutator.deletePropertyOption(board, groupByProperty!, group.option)}
/>
<Menu.Separator/>
{Object.entries(Constants.menuColors).map(([key, color]) => (
@ -134,7 +135,7 @@ export default function KanbanColumnHeader(props: Props): JSX.Element {
key={key}
id={key}
name={color}
onClick={() => mutator.changePropertyOptionColor(boardTree.board, boardTree.groupByProperty!, group.option, key)}
onClick={() => mutator.changePropertyOptionColor(board, groupByProperty!, group.option, key)}
/>
))}
</>}

View File

@ -6,25 +6,25 @@ import {IntlShape} from 'react-intl'
import {useDrop} from 'react-dnd'
import mutator from '../../mutator'
import {BoardTree, BoardTreeGroup} from '../../viewModel/boardTree'
import Button from '../../widgets/buttons/button'
import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper'
import ShowIcon from '../../widgets/icons/show'
import Label from '../../widgets/label'
import {Card} from '../../blocks/card'
import {BoardGroup} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
type Props = {
boardTree: BoardTree
group: BoardTreeGroup
activeView: BoardView
group: BoardGroup
intl: IntlShape
readonly: boolean
onDrop: (card: Card) => void
}
export default function KanbanHiddenColumnItem(props: Props): JSX.Element {
const {boardTree, intl, group} = props
const {activeView} = boardTree
const {activeView, intl, group} = props
const [{isOver}, drop] = useDrop(() => ({
accept: 'card',
collect: (monitor) => ({

View File

@ -1,7 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useRef, useMemo} from 'react'
import EasyMDE from 'easymde'
import SimpleMDE from 'react-simplemde-editor'
import {Utils} from '../utils'
@ -10,7 +9,6 @@ import './markdownEditor.scss'
type Props = {
text?: string
placeholderText?: string
uniqueId?: string
className?: string
readonly?: boolean
@ -21,11 +19,29 @@ type Props = {
}
const MarkdownEditor = (props: Props): JSX. Element => {
const {placeholderText, uniqueId, onFocus, onBlur, onChange, text} = props
const {placeholderText, onFocus, onBlur, onChange, text} = props
const [uniqueId] = useState(Utils.createGuid())
const [isEditing, setIsEditing] = useState(false)
const [active, setActive] = useState(false)
const [editorInstance, setEditorInstance] = useState<EasyMDE>()
const [editorInstance, setEditorInstance] = useState<any>()
const editorOptions = useMemo(() => ({
autoDownloadFontAwesome: true,
toolbar: false,
status: false,
autofocus: true,
spellChecker: true,
nativeSpellcheck: true,
minHeight: '10px',
shortcuts: {
toggleStrikethrough: 'Cmd-.',
togglePreview: null,
drawImage: null,
drawLink: null,
toggleSideBySide: null,
toggleFullScreen: null,
},
}), [])
const showEditor = (): void => {
const cm = editorInstance?.codemirror
@ -52,20 +68,35 @@ const MarkdownEditor = (props: Props): JSX. Element => {
const stateAndPropsRef = useRef(stateAndPropsValue)
stateAndPropsRef.current = stateAndPropsValue
const mdeOptions = useMemo(() => ({
autoDownloadFontAwesome: true,
toolbar: false,
status: false,
spellChecker: true,
nativeSpellcheck: true,
minHeight: '10px',
shortcuts: {
toggleStrikethrough: 'Cmd-.',
togglePreview: null,
drawImage: null,
drawLink: null,
toggleSideBySide: null,
toggleFullScreen: null,
const editorEvents = useMemo(() => ({
change: (instance: any) => {
if (stateAndPropsRef.current.isEditing) {
const newText = instance.getValue()
stateAndPropsRef.current.onChange?.(newText)
}
},
blur: (instance: any) => {
const newText = instance.getValue()
const oldText = text || ''
if (newText !== oldText && stateAndPropsRef.current.onChange) {
stateAndPropsRef.current.onChange(newText)
}
stateAndPropsRef.current.setActive(false)
if (stateAndPropsRef.current.onBlur) {
stateAndPropsRef.current.onBlur(newText)
}
stateAndPropsRef.current.setIsEditing(false)
},
focus: () => {
stateAndPropsRef.current.setActive(true)
stateAndPropsRef.current.setIsEditing(true)
if (stateAndPropsRef.current.onFocus) {
stateAndPropsRef.current.onFocus()
}
},
}), [])
@ -106,50 +137,10 @@ const MarkdownEditor = (props: Props): JSX. Element => {
>
<SimpleMDE
id={uniqueId}
getMdeInstance={(instance) => {
setEditorInstance(instance)
// BUGBUG: This breaks auto-lists
// instance.codemirror.setOption("extraKeys", {
// "Ctrl-Enter": (cm) => {
// cm.getInputField().blur()
// }
// })
}}
getMdeInstance={setEditorInstance}
value={text}
events={{
change: (instance: any) => {
if (stateAndPropsRef.current.isEditing) {
const newText = instance.getValue()
stateAndPropsRef.current.onChange?.(newText)
}
},
blur: (instance: any) => {
const newText = instance.getValue()
const oldText = text || ''
if (newText !== oldText && stateAndPropsRef.current.onChange) {
stateAndPropsRef.current.onChange(newText)
}
stateAndPropsRef.current.setActive(false)
if (stateAndPropsRef.current.onBlur) {
stateAndPropsRef.current.onBlur(newText)
}
stateAndPropsRef.current.setIsEditing(false)
},
focus: () => {
stateAndPropsRef.current.setActive(true)
stateAndPropsRef.current.setIsEditing(true)
if (stateAndPropsRef.current.onFocus) {
stateAndPropsRef.current.onFocus()
}
},
}}
options={mdeOptions}
events={editorEvents}
options={editorOptions}
/>
</div>)

View File

@ -8,19 +8,19 @@ import {render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import {IUser} from '../../../user'
import {MutableCard} from '../../../blocks/card'
import {createCard} from '../../../blocks/card'
import CreatedBy from './createdBy'
describe('components/properties/createdBy', () => {
test('should match snapshot', () => {
const card = new MutableCard()
const card = createCard()
card.createdBy = 'user-id-1'
const mockStore = configureStore([])
const store = mockStore({
currentWorkspaceUsers: {
byId: {
users: {
workspaceUsers: {
'user-id-1': {username: 'username_1'} as IUser,
},
},

View File

@ -5,7 +5,7 @@ exports[`componnets/properties/lastModifiedAt should match snapshot 1`] = `
<div
class="LastModifiedAt octo-propertyvalue"
>
June 10, 4:22 PM
June 15, 4:22 PM
</div>
</div>
`;

View File

@ -3,16 +3,10 @@
import React from 'react'
import {render} from '@testing-library/react'
import {IntlProvider} from 'react-intl'
import {CardTree, CardTreeContext, MutableCardTree} from '../../../viewModel/cardTree'
import {MutableCard} from '../../../blocks/card'
import {MutableBoardTree} from '../../../viewModel/boardTree'
import {MutableBoard} from '../../../blocks/board'
import {MutableBlock} from '../../../blocks/block'
import {createCard} from '../../../blocks/card'
import {createCommentBlock} from '../../../blocks/commentBlock'
import LastModifiedAt from './lastModifiedAt'
@ -20,35 +14,22 @@ const wrapIntl = (children: any) => <IntlProvider locale='en'>{children}</IntlPr
describe('componnets/properties/lastModifiedAt', () => {
test('should match snapshot', () => {
const cardTree = new MutableCardTree(
new MutableCard({
updateAt: Date.parse('15 Jun 2021 16:22:00'),
}),
)
const card = new MutableCard()
const card = createCard()
card.id = 'card-id-1'
card.modifiedBy = 'user-id-1'
card.updateAt = Date.parse('10 Jun 2021 16:22:00')
const boardTree = new MutableBoardTree(new MutableBoard([]), {})
const block = new MutableBlock()
block.modifiedBy = 'user-id-1'
block.parentId = 'card-id-1'
block.type = 'comment'
block.updateAt = Date.parse('15 Jun 2021 16:22:00')
boardTree.rawBlocks.push(block)
const cardTrees:{ [key: string]: CardTree | undefined } = {}
cardTrees[card.id] = new MutableCardTree(card)
const comment = createCommentBlock()
comment.modifiedBy = 'user-id-1'
comment.parentId = 'card-id-1'
comment.updateAt = Date.parse('15 Jun 2021 16:22:00')
const component = wrapIntl(
<CardTreeContext.Provider value={cardTree}>
<LastModifiedAt
card={card}
cardTree={cardTree}
/>
</CardTreeContext.Provider>,
contents={[]}
comments={[comment]}
/>,
)
const {container} = render(component)

View File

@ -6,23 +6,24 @@ import React from 'react'
import {useIntl} from 'react-intl'
import {Card} from '../../../blocks/card'
import {CardTree} from '../../../viewModel/cardTree'
import {IBlock} from '../../../blocks/block'
import {Block} from '../../../blocks/block'
import {ContentBlock} from '../../../blocks/contentBlock'
import {CommentBlock} from '../../../blocks/commentBlock'
import {Utils} from '../../../utils'
type Props = {
card: Card,
cardTree?: CardTree
contents: Array<ContentBlock|ContentBlock[]>
comments: CommentBlock[]
}
const LastModifiedAt = (props: Props): JSX.Element => {
const intl = useIntl()
let latestBlock: IBlock = props.card
if (props.cardTree) {
const sortedBlocks = props.cardTree.allBlocks.
filter((block) => block.parentId === props.card.id || block.id === props.card.id).
sort((a, b) => b.updateAt - a.updateAt)
let latestBlock: Block = props.card
if (props.card) {
const allBlocks = [props.card, ...props.contents.flat(), ...props.comments]
const sortedBlocks = allBlocks.sort((a, b) => b.updateAt - a.updateAt)
latestBlock = sortedBlocks.length > 0 ? sortedBlocks[0] : latestBlock
}

View File

@ -7,45 +7,30 @@ import {Provider as ReduxProvider} from 'react-redux'
import {render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import {MutableCardTree, CardTreeContext} from '../../../viewModel/cardTree'
import {MutableCard} from '../../../blocks/card'
import {createCard} from '../../../blocks/card'
import {IUser} from '../../../user'
import {MutableBoardTree} from '../../../viewModel/boardTree'
import {createBoard} from '../../../blocks/board'
import {MutableBoard} from '../../../blocks/board'
import {MutableBlock} from '../../../blocks/block'
import {createCommentBlock} from '../../../blocks/commentBlock'
import LastModifiedBy from './lastModifiedBy'
describe('components/properties/lastModifiedBy', () => {
test('should match snapshot', () => {
const cardTree = new MutableCardTree(
new MutableCard({
updateAt: Date.parse('15 Jun 2021 16:22:00 +05:30'),
modifiedBy: 'user-id-1',
}),
)
const card = new MutableCard()
const card = createCard()
card.id = 'card-id-1'
card.modifiedBy = 'user-id-1'
const boardTree = new MutableBoardTree(new MutableBoard([]), {
'user-id-1': {username: 'username_1'} as IUser,
})
const block = new MutableBlock()
block.modifiedBy = 'user-id-1'
block.parentId = 'card-id-1'
block.type = 'comment'
boardTree.rawBlocks.push(block)
const board = createBoard()
const comment = createCommentBlock()
comment.modifiedBy = 'user-id-1'
comment.parentId = 'card-id-1'
const mockStore = configureStore([])
const store = mockStore({
currentWorkspaceUsers: {
byId: {
users: {
workspaceUsers: {
'user-id-1': {username: 'username_1'} as IUser,
},
},
@ -53,12 +38,12 @@ describe('components/properties/lastModifiedBy', () => {
const component = (
<ReduxProvider store={store}>
<CardTreeContext.Provider value={cardTree}>
<LastModifiedBy
card={card}
boardTree={boardTree}
board={board}
contents={[]}
comments={[comment]}
/>
</CardTreeContext.Provider>
</ReduxProvider>
)

View File

@ -5,24 +5,27 @@ import React from 'react'
import {IUser} from '../../../user'
import {Card} from '../../../blocks/card'
import {BoardTree} from '../../../viewModel/boardTree'
import {IBlock} from '../../../blocks/block'
import {getCurrentWorkspaceUsersById} from '../../../store/currentWorkspaceUsers'
import {ContentBlock} from '../../../blocks/contentBlock'
import {CommentBlock} from '../../../blocks/commentBlock'
import {Board} from '../../../blocks/board'
import {Block} from '../../../blocks/block'
import {getWorkspaceUsers} from '../../../store/users'
import {useAppSelector} from '../../../store/hooks'
type Props = {
card: Card,
boardTree?: BoardTree,
board?: Board,
contents: Array<ContentBlock|ContentBlock[]>
comments: CommentBlock[],
}
const LastModifiedBy = (props: Props): JSX.Element => {
const workspaceUsersById = useAppSelector<{[key:string]: IUser}>(getCurrentWorkspaceUsersById)
const workspaceUsersById = useAppSelector<{[key:string]: IUser}>(getWorkspaceUsers)
let latestBlock: IBlock = props.card
if (props.boardTree) {
const sortedBlocks = props.boardTree?.allBlocks.
filter((block) => block.parentId === props.card.id || block.id === props.card.id).
sort((a, b) => b.updateAt - a.updateAt)
let latestBlock: Block = props.card
if (props.board) {
const allBlocks: Block[] = [props.card, ...props.contents.flat(), ...props.comments]
const sortedBlocks = allBlocks.sort((a, b) => b.updateAt - a.updateAt)
latestBlock = sortedBlocks.length > 0 ? sortedBlocks[0] : latestBlock
}

View File

@ -196,7 +196,7 @@ exports[`components/properties/user user dropdown open 1`] = `
<span
id="aria-context"
>
Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.
option username-1 selected, 1 of 1. 1 result available. Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.
</span>
</span>
<div
@ -284,9 +284,11 @@ exports[`components/properties/user user dropdown open 1`] = `
class=" css-g29tl0-MenuList"
>
<div
class=" css-gg45go-NoOptionsMessage"
class=" css-1pe17ep-option"
id="react-select-4-option-0"
tabindex="-1"
>
No options
username-1
</div>
</div>
</div>

View File

@ -17,9 +17,9 @@ const wrapIntl = (children: any) => <IntlProvider locale='en'>{children}</IntlPr
describe('components/properties/user', () => {
const mockStore = configureStore([])
const store = mockStore({
currentWorkspaceUsers: {
byId: {
const state = {
users: {
workspaceUsers: {
'user-id-1': {
id: 'user-id-1',
username: 'username-1',
@ -31,9 +31,10 @@ describe('components/properties/user', () => {
},
},
},
})
}
test('not readonly not existing user', async () => {
const store = mockStore(state)
const component = wrapIntl(
<ReduxProvider store={store}>
<UserProperty
@ -56,6 +57,7 @@ describe('components/properties/user', () => {
})
test('not readonly', async () => {
const store = mockStore(state)
const component = wrapIntl(
<ReduxProvider store={store}>
<UserProperty
@ -78,6 +80,7 @@ describe('components/properties/user', () => {
})
test('readonly view', async () => {
const store = mockStore(state)
const component = wrapIntl(
<ReduxProvider store={store}>
<UserProperty
@ -100,6 +103,7 @@ describe('components/properties/user', () => {
})
test('user dropdown open', async () => {
const store = mockStore(state)
const component = wrapIntl(
<ReduxProvider store={store}>
<UserProperty

View File

@ -5,7 +5,7 @@ import React from 'react'
import Select from 'react-select'
import {IUser} from '../../../user'
import {getCurrentWorkspaceUsers, getCurrentWorkspaceUsersById} from '../../../store/currentWorkspaceUsers'
import {getWorkspaceUsersList, getWorkspaceUsers} from '../../../store/users'
import {useAppSelector} from '../../../store/hooks'
import './user.scss'
@ -18,8 +18,8 @@ type Props = {
}
const UserProperty = (props: Props): JSX.Element => {
const workspaceUsers = useAppSelector<IUser[]>(getCurrentWorkspaceUsers)
const workspaceUsersById = useAppSelector<{[key:string]: IUser}>(getCurrentWorkspaceUsersById)
const workspaceUsers = useAppSelector<IUser[]>(getWorkspaceUsersList)
const workspaceUsersById = useAppSelector<{[key:string]: IUser}>(getWorkspaceUsers)
if (props.readonly) {
return (<div className='UserProperty octo-propertyvalue readonly'>{workspaceUsersById[props.value]?.username || props.value}</div>)

View File

@ -4,12 +4,13 @@
import React, {useState, useCallback, useEffect, useRef} from 'react'
import {useIntl} from 'react-intl'
import {IPropertyOption, IPropertyTemplate, PropertyType} from '../blocks/board'
import {Board, IPropertyOption, IPropertyTemplate, PropertyType} from '../blocks/board'
import {Card} from '../blocks/card'
import {ContentBlock} from '../blocks/contentBlock'
import {CommentBlock} from '../blocks/commentBlock'
import mutator from '../mutator'
import {OctoUtils} from '../octoUtils'
import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree'
import Editable from '../widgets/editable'
import ValueSelector from '../widgets/valueSelector'
@ -18,8 +19,6 @@ import Label from '../widgets/label'
import EditableDayPicker from '../widgets/editableDayPicker'
import Switch from '../widgets/switch'
import {CardTree} from '../viewModel/cardTree'
import UserProperty from './properties/user/user'
import MultiSelectProperty from './properties/multiSelect'
import URLProperty from './properties/link/link'
@ -29,21 +28,22 @@ import CreatedAt from './properties/createdAt/createdAt'
import CreatedBy from './properties/createdBy/createdBy'
type Props = {
boardTree?: BoardTree
cardTree?: CardTree
board: Board
readOnly: boolean
card: Card
contents: Array<ContentBlock|ContentBlock[]>
comments: CommentBlock[]
propertyTemplate: IPropertyTemplate
emptyDisplayValue: string
}
const PropertyValueElement = (props:Props): JSX.Element => {
const [value, setValue] = useState(props.card.properties[props.propertyTemplate.id] || '')
const [value, setValue] = useState(props.card.fields.properties[props.propertyTemplate.id] || '')
const [serverValue, setServerValue] = useState(props.card.fields.properties[props.propertyTemplate.id] || '')
const {card, propertyTemplate, readOnly, emptyDisplayValue, boardTree, cardTree} = props
const {card, propertyTemplate, readOnly, emptyDisplayValue, board, contents, comments} = props
const intl = useIntl()
const propertyValue = card.properties[propertyTemplate.id]
const propertyValue = card.fields.properties[propertyTemplate.id]
const displayValue = OctoUtils.propertyDisplayValue(card, propertyValue, propertyTemplate, intl)
const finalDisplayValue = displayValue || emptyDisplayValue
const [open, setOpen] = useState(false)
@ -101,13 +101,13 @@ const PropertyValueElement = (props:Props): JSX.Element => {
if (propertyTemplate.type === 'multiSelect') {
return (
<MultiSelectProperty
isEditable={!readOnly && Boolean(boardTree)}
isEditable={!readOnly && Boolean(board)}
emptyValue={emptyDisplayValue}
propertyTemplate={propertyTemplate}
propertyValue={propertyValue}
onChange={(newValue) => mutator.changePropertyValue(card, propertyTemplate.id, newValue)}
onChangeColor={(option: IPropertyOption, colorId: string) => mutator.changePropertyOptionColor(boardTree!.board, propertyTemplate, option, colorId)}
onDeleteOption={(option: IPropertyOption) => mutator.deletePropertyOption(boardTree!, propertyTemplate, option)}
onChangeColor={(option: IPropertyOption, colorId: string) => mutator.changePropertyOptionColor(board, propertyTemplate, option, colorId)}
onDeleteOption={(option: IPropertyOption) => mutator.deletePropertyOption(board, propertyTemplate, option)}
onCreate={
async (newValue, currentValues) => {
const option: IPropertyOption = {
@ -116,7 +116,7 @@ const PropertyValueElement = (props:Props): JSX.Element => {
color: 'propColorDefault',
}
currentValues.push(option)
await mutator.insertPropertyOption(boardTree!, propertyTemplate, option, 'add property option')
await mutator.insertPropertyOption(board, propertyTemplate, option, 'add property option')
mutator.changePropertyValue(card, propertyTemplate.id, currentValues.map((v) => v.id))
}
}
@ -132,7 +132,7 @@ const PropertyValueElement = (props:Props): JSX.Element => {
propertyColorCssClassName = cardPropertyValue.color
}
if (readOnly || !boardTree || !open) {
if (readOnly || !board || !open) {
return (
<div
className='octo-propertyvalue'
@ -152,10 +152,10 @@ const PropertyValueElement = (props:Props): JSX.Element => {
mutator.changePropertyValue(card, propertyTemplate.id, newValue)
}}
onChangeColor={(option: IPropertyOption, colorId: string): void => {
mutator.changePropertyOptionColor(boardTree.board, propertyTemplate, option, colorId)
mutator.changePropertyOptionColor(board, propertyTemplate, option, colorId)
}}
onDeleteOption={(option: IPropertyOption): void => {
mutator.deletePropertyOption(boardTree, propertyTemplate, option)
mutator.deletePropertyOption(board, propertyTemplate, option)
}}
onCreate={
async (newValue) => {
@ -164,7 +164,7 @@ const PropertyValueElement = (props:Props): JSX.Element => {
value: newValue,
color: 'propColorDefault',
}
await mutator.insertPropertyOption(boardTree, propertyTemplate, option, 'add property option')
await mutator.insertPropertyOption(board, propertyTemplate, option, 'add property option')
mutator.changePropertyValue(card, propertyTemplate.id, option.id)
}
}
@ -218,7 +218,9 @@ const PropertyValueElement = (props:Props): JSX.Element => {
return (
<LastModifiedBy
card={card}
boardTree={boardTree}
board={board}
contents={contents}
comments={comments}
/>
)
} else if (propertyTemplate.type === 'createdTime') {
@ -229,7 +231,8 @@ const PropertyValueElement = (props:Props): JSX.Element => {
return (
<LastModifiedAt
card={card}
cardTree={cardTree}
contents={contents}
comments={comments}
/>
)
}

View File

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import {useRouteMatch} from 'react-router'
import {generatePath, useRouteMatch} from 'react-router'
import {ISharing} from '../blocks/sharing'
@ -26,7 +26,7 @@ const ShareBoardComponent = React.memo((props: Props): JSX.Element => {
const [wasCopied, setWasCopied] = useState(false)
const [sharing, setSharing] = useState<ISharing|undefined>(undefined)
const intl = useIntl()
const match = useRouteMatch<{workspaceId?: string}>()
const match = useRouteMatch<{workspaceId?: string, boardId: string, viewId: string}>()
const loadData = async () => {
const newSharing = await client.getSharing(props.boardId)
@ -76,9 +76,18 @@ const ShareBoardComponent = React.memo((props: Props): JSX.Element => {
shareUrl.searchParams.set('r', readToken)
if (match.params.workspaceId) {
shareUrl.pathname = Utils.buildURL(`/workspace/${match.params.workspaceId}/shared`)
const newPath = generatePath('/workspace/:workspaceId/shared/:boardId/:viewId', {
boardId: match.params.boardId,
viewId: match.params.viewId,
workspaceId: match.params.workspaceId,
})
shareUrl.pathname = newPath
} else {
shareUrl.pathname = Utils.buildURL('/shared')
const newPath = generatePath('/shared/:boardId/:viewId', {
boardId: match.params.boardId,
viewId: match.params.viewId,
})
shareUrl.pathname = newPath
}
return (

View File

@ -47,12 +47,12 @@ const BoardTemplateMenuItem = React.memo((props: Props) => {
return (
<Menu.Text
key={boardTemplate.id}
id={boardTemplate.id}
key={boardTemplate.id || ''}
id={boardTemplate.id || ''}
name={displayName}
icon={<div className='Icon'>{boardTemplate.fields.icon}</div>}
onClick={() => {
addBoardFromTemplate(intl, props.showBoard, boardTemplate.id, activeBoardId, isGlobal)
addBoardFromTemplate(intl, props.showBoard, boardTemplate.id || '', activeBoardId, isGlobal)
}}
rightIcon={!isGlobal &&
<MenuWrapper stopPropagationOnToggle={true}>
@ -63,7 +63,7 @@ const BoardTemplateMenuItem = React.memo((props: Props) => {
id='edit'
name={intl.formatMessage({id: 'Sidebar.edit-template', defaultMessage: 'Edit'})}
onClick={() => {
props.showBoard(boardTemplate.id)
props.showBoard(boardTemplate.id || '')
}}
/>
<Menu.Text

View File

@ -8,7 +8,7 @@ import {sendFlashMessage} from '../../components/flashMessages'
import client from '../../octoClient'
import {Utils} from '../../utils'
import Button from '../../widgets/buttons/button'
import {getCurrentWorkspace, fetchCurrentWorkspace} from '../../store/currentWorkspace'
import {getWorkspace, fetchWorkspace} from '../../store/workspace'
import {useAppSelector, useAppDispatch} from '../../store/hooks'
import Modal from '../modal'
@ -22,13 +22,13 @@ type Props = {
const RegistrationLink = React.memo((props: Props) => {
const {onClose} = props
const intl = useIntl()
const workspace = useAppSelector<IWorkspace|null>(getCurrentWorkspace)
const workspace = useAppSelector<IWorkspace|null>(getWorkspace)
const dispatch = useAppDispatch()
const [wasCopied, setWasCopied] = useState(false)
useEffect(() => {
dispatch(fetchCurrentWorkspace())
dispatch(fetchWorkspace())
}, [])
const regenerateToken = async () => {
@ -36,7 +36,7 @@ const RegistrationLink = React.memo((props: Props) => {
const accept = window.confirm(intl.formatMessage({id: 'RegistrationLink.confirmRegenerateToken', defaultMessage: 'This will invalidate previously shared links. Continue?'}))
if (accept) {
await client.regenerateWorkspaceSignupToken()
await dispatch(fetchCurrentWorkspace())
await dispatch(fetchWorkspace())
setWasCopied(false)
const description = intl.formatMessage({id: 'RegistrationLink.tokenRegenerated', defaultMessage: 'Registration link regenerated'})

View File

@ -2,13 +2,15 @@
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react'
import {IWorkspace} from '../../blocks/workspace'
import {getActiveThemeName, loadTheme} from '../../theme'
import {WorkspaceTree} from '../../viewModel/workspaceTree'
import IconButton from '../../widgets/buttons/iconButton'
import HamburgerIcon from '../../widgets/icons/hamburger'
import HideSidebarIcon from '../../widgets/icons/hideSidebar'
import ShowSidebarIcon from '../../widgets/icons/showSidebar'
import {getSortedBoards} from '../../store/boards'
import {getSortedViews} from '../../store/views'
import {getWorkspace} from '../../store/workspace'
import {useAppSelector} from '../../store/hooks'
import './sidebar.scss'
@ -18,8 +20,6 @@ import SidebarSettingsMenu from './sidebarSettingsMenu'
import SidebarUserMenu from './sidebarUserMenu'
type Props = {
workspace?: IWorkspace
workspaceTree: WorkspaceTree,
activeBoardId?: string
activeViewId?: string
}
@ -27,6 +27,8 @@ type Props = {
const Sidebar = React.memo((props: Props) => {
const [isHidden, setHidden] = useState(false)
const [whiteLogo, setWhiteLogo] = useState(false)
const boards = useAppSelector(getSortedBoards)
const views = useAppSelector(getSortedViews)
useEffect(() => {
const theme = loadTheme()
@ -36,13 +38,11 @@ const Sidebar = React.memo((props: Props) => {
}
}, [])
const {workspace, workspaceTree} = props
if (!workspaceTree) {
const workspace = useAppSelector(getWorkspace)
if (!boards) {
return <div/>
}
const {boards, views} = workspaceTree
if (isHidden) {
return (
<div className='Sidebar octo-sidebar hidden'>
@ -98,7 +98,7 @@ const Sidebar = React.memo((props: Props) => {
board={board}
activeBoardId={props.activeBoardId}
activeViewId={props.activeViewId}
nextBoardId={nextBoardId}
nextBoardId={board.id === props.activeBoardId ? nextBoardId : undefined}
/>
)
})
@ -108,7 +108,6 @@ const Sidebar = React.memo((props: Props) => {
<div className='octo-spacer'/>
<SidebarAddBoardMenu
workspaceTree={props.workspaceTree}
activeBoardId={props.activeBoardId}
/>

View File

@ -4,35 +4,34 @@ import React, {useEffect, useCallback} from 'react'
import {FormattedMessage, useIntl, IntlShape} from 'react-intl'
import {generatePath, useHistory, useRouteMatch} from 'react-router-dom'
import {MutableBoard} from '../../blocks/board'
import {MutableBoardView} from '../../blocks/boardView'
import {Board, createBoard} from '../../blocks/board'
import {createBoardView} from '../../blocks/boardView'
import mutator from '../../mutator'
import octoClient from '../../octoClient'
import {WorkspaceTree} from '../../viewModel/workspaceTree'
import AddIcon from '../../widgets/icons/add'
import BoardIcon from '../../widgets/icons/board'
import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper'
import {useAppDispatch, useAppSelector} from '../../store/hooks'
import {getGlobalTemplates, fetchGlobalTemplates} from '../../store/globalTemplates'
import {getSortedTemplates} from '../../store/boards'
import BoardTemplateMenuItem from './boardTemplateMenuItem'
import './sidebarAddBoardMenu.scss'
type Props = {
workspaceTree: WorkspaceTree,
activeBoardId?: string
}
const addBoardClicked = async (showBoard: (id: string) => void, intl: IntlShape, activeBoardId?: string) => {
const oldBoardId = activeBoardId
const board = new MutableBoard()
const board = createBoard()
board.rootId = board.id
const view = new MutableBoardView()
view.viewType = 'board'
const view = createBoardView()
view.fields.viewType = 'board'
view.parentId = board.id
view.rootId = board.rootId
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board view'})
@ -51,12 +50,19 @@ const addBoardClicked = async (showBoard: (id: string) => void, intl: IntlShape,
)
}
const addBoardTemplateClicked = async (showBoard: (id: string) => void, activeBoardId?: string) => {
const boardTemplate = new MutableBoard()
const addBoardTemplateClicked = async (showBoard: (id: string) => void, intl: IntlShape, activeBoardId?: string) => {
const boardTemplate = createBoard()
boardTemplate.rootId = boardTemplate.id
boardTemplate.isTemplate = true
await mutator.insertBlock(
boardTemplate,
boardTemplate.fields.isTemplate = true
const view = createBoardView()
view.fields.viewType = 'board'
view.parentId = boardTemplate.id
view.rootId = boardTemplate.rootId
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board view'})
await mutator.insertBlocks(
[boardTemplate, view],
'add board template',
async () => {
showBoard(boardTemplate.id)
@ -69,13 +75,15 @@ const addBoardTemplateClicked = async (showBoard: (id: string) => void, activeBo
}
const SidebarAddBoardMenu = (props: Props): JSX.Element => {
const globalTemplates = useAppSelector<MutableBoard[]>(getGlobalTemplates)
const globalTemplates = useAppSelector<Board[]>(getGlobalTemplates)
const dispatch = useAppDispatch()
const history = useHistory()
const match = useRouteMatch()
const match = useRouteMatch<{boardId: string, viewId?: string}>()
const showBoard = useCallback((boardId) => {
const newPath = generatePath(match.path, {...match.params, boardId: boardId || ''})
const params = {...match.params, boardId: boardId || ''}
delete params.viewId
const newPath = generatePath(match.path, params)
history.push(newPath)
}, [match, history])
@ -85,10 +93,10 @@ const SidebarAddBoardMenu = (props: Props): JSX.Element => {
}
}, [octoClient.workspaceId])
const {workspaceTree} = props
const intl = useIntl()
const templates = useAppSelector(getSortedTemplates)
if (!workspaceTree) {
if (!templates) {
return <div/>
}
@ -102,7 +110,7 @@ const SidebarAddBoardMenu = (props: Props): JSX.Element => {
/>
</div>
<Menu position='top'>
{workspaceTree.boardTemplates.length > 0 && <>
{templates.length > 0 && <>
<Menu.Label>
<b>
<FormattedMessage
@ -115,7 +123,7 @@ const SidebarAddBoardMenu = (props: Props): JSX.Element => {
<Menu.Separator/>
</>}
{workspaceTree.boardTemplates.map((boardTemplate) => (
{templates.map((boardTemplate) => (
<BoardTemplateMenuItem
key={boardTemplate.id}
boardTemplate={boardTemplate}
@ -125,7 +133,7 @@ const SidebarAddBoardMenu = (props: Props): JSX.Element => {
/>
))}
{globalTemplates.map((boardTemplate: MutableBoard) => (
{globalTemplates.map((boardTemplate: Board) => (
<BoardTemplateMenuItem
key={boardTemplate.id}
boardTemplate={boardTemplate}
@ -146,7 +154,7 @@ const SidebarAddBoardMenu = (props: Props): JSX.Element => {
icon={<AddIcon/>}
id='add-template'
name={intl.formatMessage({id: 'Sidebar.add-template', defaultMessage: 'New template'})}
onClick={() => addBoardTemplateClicked(showBoard, props.activeBoardId)}
onClick={() => addBoardTemplateClicked(showBoard, intl, props.activeBoardId)}
/>
</Menu>
</MenuWrapper>

View File

@ -21,7 +21,7 @@ import MenuWrapper from '../../widgets/menuWrapper'
import './sidebarBoardItem.scss'
type Props = {
views: readonly BoardView[]
views: BoardView[]
board: Board
activeBoardId?: string
activeViewId?: string
@ -107,7 +107,7 @@ const SidebarBoardItem = React.memo((props: Props) => {
className='octo-sidebar-title'
title={displayTitle}
>
{board.icon ? `${board.icon} ${displayTitle}` : displayTitle}
{board.fields.icon ? `${board.fields.icon} ${displayTitle}` : displayTitle}
</div>
<MenuWrapper stopPropagationOnToggle={true}>
<IconButton icon={<OptionsIcon/>}/>
@ -140,7 +140,7 @@ const SidebarBoardItem = React.memo((props: Props) => {
name={intl.formatMessage({id: 'Sidebar.duplicate-board', defaultMessage: 'Duplicate board'})}
icon={<DuplicateIcon/>}
onClick={() => {
duplicateBoard(board.id)
duplicateBoard(board.id || '')
}}
/>
@ -148,7 +148,7 @@ const SidebarBoardItem = React.memo((props: Props) => {
id='templateFromBoard'
name={intl.formatMessage({id: 'Sidebar.template-from-board', defaultMessage: 'New template from board'})}
onClick={() => {
addTemplateFromBoard(board.id)
addTemplateFromBoard(board.id || '')
}}
/>
</Menu>
@ -167,7 +167,7 @@ const SidebarBoardItem = React.memo((props: Props) => {
className={`octo-sidebar-item subitem ${view.id === props.activeViewId ? 'active' : ''}`}
onClick={() => showView(view.id, board.id)}
>
{iconForViewType(view.viewType)}
{iconForViewType(view.fields.viewType)}
<div
className='octo-sidebar-title'
title={view.title || intl.formatMessage({id: 'Sidebar.untitled-view', defaultMessage: '(Untitled View)'})}

View File

@ -11,7 +11,7 @@ import LogoWithNameIcon from '../../widgets/icons/logoWithName'
import LogoWithNameWhiteIcon from '../../widgets/icons/logoWithNameWhite'
import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper'
import {getCurrentUser} from '../../store/currentUser'
import {getMe} from '../../store/users'
import {useAppSelector} from '../../store/hooks'
import ModalWrapper from '../modalWrapper'
@ -30,7 +30,7 @@ const SidebarUserMenu = React.memo((props: Props) => {
const history = useHistory()
const [showRegistrationLinkDialog, setShowRegistrationLinkDialog] = useState(false)
const {whiteLogo, showVersionBadge, showAccountActions} = props
const user = useAppSelector<IUser|null>(getCurrentUser)
const user = useAppSelector<IUser|null>(getMe)
const intl = useIntl()
return (
<div className='SidebarUserMenu'>

View File

@ -864,7 +864,7 @@ exports[`components/table/Table extended should match snapshot with UpdatedAt 1`
<div
class="LastModifiedAt octo-propertyvalue"
>
June 20, 12:22 PM
June 22, 11:23 AM
</div>
</div>
</div>

View File

@ -118,7 +118,7 @@ exports[`should match snapshot with Group 1`] = `
exports[`should match snapshot, add new 1`] = `
<div>
<div
class="octo-group-header-cell"
class="octo-group-header-cell expanded"
draggable="true"
style="opacity: 1;"
>
@ -184,7 +184,7 @@ exports[`should match snapshot, add new 1`] = `
exports[`should match snapshot, edit title 1`] = `
<div>
<div
class="octo-group-header-cell"
class="octo-group-header-cell expanded"
draggable="true"
style="opacity: 1;"
>
@ -250,7 +250,7 @@ exports[`should match snapshot, edit title 1`] = `
exports[`should match snapshot, hide group 1`] = `
<div>
<div
class="octo-group-header-cell expanded"
class="octo-group-header-cell"
draggable="true"
style="opacity: 1;"
>

View File

@ -15,7 +15,7 @@ import {HTML5Backend} from 'react-dnd-html5-backend'
import {TestBlockFactory} from '../../test/testBlockFactory'
import {FetchMock} from '../../test/fetchMock'
import {MutableBoardTree} from '../../viewModel/boardTree'
import {BoardView} from '../../blocks/boardView'
import {IUser} from '../../user'
@ -40,70 +40,90 @@ const wrapProviders = (children: any) => {
describe('components/table/Table', () => {
const board = TestBlockFactory.createBoard()
const view = TestBlockFactory.createBoardView(board)
view.viewType = 'table'
view.groupById = undefined
view.visiblePropertyIds = ['property1', 'property2']
view.fields.viewType = 'table'
view.fields.groupById = undefined
view.fields.visiblePropertyIds = ['property1', 'property2']
const view2 = TestBlockFactory.createBoardView(board)
view2.sortOptions = []
view2.fields.sortOptions = []
const card = TestBlockFactory.createCard(board)
const cardTemplate = TestBlockFactory.createCard(board)
cardTemplate.isTemplate = true
cardTemplate.fields.isTemplate = true
test('should match snapshot', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).not.toBeUndefined()
if (!boardTree) {
fail('sync')
const state = {
users: {
workspaceUsers: {
'user-id-1': {username: 'username_1'} as IUser,
'user-id-2': {username: 'username_2'} as IUser,
'user-id-3': {username: 'username_3'} as IUser,
'user-id-4': {username: 'username_4'} as IUser,
},
},
comments: {
comments: {},
},
contents: {
contents: {},
},
cards: {
cards: {
[card.id]: card,
},
},
}
expect(FetchMock.fn).toBeCalledTimes(1)
expect(boardTree.cards).toBeDefined()
expect(boardTree.cards).toEqual([card])
test('should match snapshot', async () => {
const callback = jest.fn()
const addCard = jest.fn()
const mockStore = configureStore([])
const store = mockStore(state)
const component = wrapProviders(
<ReduxProvider store={store}>
<Table
boardTree={boardTree!}
board={board}
activeView={view}
visibleGroups={[]}
cards={[card]}
views={[view, view2]}
selectedCardIds={[]}
readonly={false}
cardIdToFocusOnRender=''
showCard={callback}
addCard={addCard}
onCardClicked={jest.fn()}
/>,
/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('should match snapshot, read-only', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const callback = jest.fn()
const addCard = jest.fn()
const mockStore = configureStore([])
const store = mockStore(state)
const component = wrapProviders(
<ReduxProvider store={store}>
<Table
boardTree={boardTree!}
board={board}
activeView={view}
visibleGroups={[]}
cards={[card]}
views={[view, view2]}
selectedCardIds={[]}
readonly={true}
cardIdToFocusOnRender=''
showCard={callback}
addCard={addCard}
onCardClicked={jest.fn()}
/>,
/>
</ReduxProvider>,
)
const {container} = render(component)
@ -111,33 +131,34 @@ describe('components/table/Table', () => {
})
test('should match snapshot with GroupBy', async () => {
// Sync
view.groupById = 'property1'
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).not.toBeUndefined()
if (!boardTree) {
fail('sync')
}
expect(FetchMock.fn).toBeCalledTimes(1)
expect(boardTree.cards).toBeDefined()
expect(boardTree.cards).toEqual([card])
const callback = jest.fn()
const addCard = jest.fn()
const mockStore = configureStore([])
const store = mockStore(state)
const component = wrapProviders(
<ReduxProvider store={store}>
<Table
boardTree={boardTree!}
board={board}
activeView={{...view, fields: {...view.fields, groupById: 'property1'}} as BoardView}
visibleGroups={[{option: {id: '', value: 'test', color: ''}, cards: []}]}
groupByProperty={{
id: '',
name: 'Property 1',
type: 'text',
options: [{id: 'property1', value: 'Property 1', color: ''}],
}}
cards={[card]}
views={[view, view2]}
selectedCardIds={[]}
readonly={false}
cardIdToFocusOnRender=''
showCard={callback}
addCard={addCard}
onCardClicked={jest.fn()}
/>,
/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
@ -145,11 +166,31 @@ describe('components/table/Table', () => {
})
describe('components/table/Table extended', () => {
const state = {
users: {
workspaceUsers: {
'user-id-1': {username: 'username_1'} as IUser,
'user-id-2': {username: 'username_2'} as IUser,
'user-id-3': {username: 'username_3'} as IUser,
'user-id-4': {username: 'username_4'} as IUser,
},
},
comments: {
comments: {},
},
contents: {
contents: {},
},
cards: {
cards: {},
},
}
test('should match snapshot with CreatedBy', async () => {
const board = TestBlockFactory.createBoard()
const dateCreatedId = Utils.createGuid()
board.cardProperties.push({
board.fields.cardProperties.push({
id: dateCreatedId,
name: 'Date Created',
type: 'createdTime',
@ -163,27 +204,20 @@ describe('components/table/Table extended', () => {
card2.createAt = Date.parse('15 Jun 2021 16:22:00')
const view = TestBlockFactory.createBoardView(board)
view.viewType = 'table'
view.groupById = undefined
view.visiblePropertyIds = ['property1', 'property2', dateCreatedId]
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, card1, card2, view])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).not.toBeUndefined()
if (!boardTree) {
fail('sync')
}
view.fields.viewType = 'table'
view.fields.groupById = undefined
view.fields.visiblePropertyIds = ['property1', 'property2', dateCreatedId]
const callback = jest.fn()
const addCard = jest.fn()
const mockStore = configureStore([])
const store = mockStore({
currentWorkspaceUsers: {
byId: {
'user-id-1': {username: 'username_1'} as IUser,
'user-id-2': {username: 'username_2'} as IUser,
...state,
cards: {
cards: {
[card1.id]: card1,
[card2.id]: card2,
},
},
})
@ -191,7 +225,11 @@ describe('components/table/Table extended', () => {
const component = wrapProviders(
<ReduxProvider store={store}>
<Table
boardTree={boardTree!}
board={board}
activeView={view}
visibleGroups={[]}
cards={[card1, card2]}
views={[view]}
selectedCardIds={[]}
readonly={false}
cardIdToFocusOnRender=''
@ -209,7 +247,7 @@ describe('components/table/Table extended', () => {
const board = TestBlockFactory.createBoard()
const dateUpdatedId = Utils.createGuid()
board.cardProperties.push({
board.fields.cardProperties.push({
id: dateUpdatedId,
name: 'Date Updated',
type: 'updatedTime',
@ -232,32 +270,53 @@ describe('components/table/Table extended', () => {
card2Text.type = 'text'
card2Text.updateAt = Date.parse('22 Jun 2021 11:23:00')
card2.fields.contentOrder = [card2Text.id]
const view = TestBlockFactory.createBoardView(board)
view.viewType = 'table'
view.groupById = undefined
view.visiblePropertyIds = ['property1', 'property2', dateUpdatedId]
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, card1, card2, view, card2Comment, card2Text])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).not.toBeUndefined()
if (!boardTree) {
fail('sync')
}
view.fields.viewType = 'table'
view.fields.groupById = undefined
view.fields.visiblePropertyIds = ['property1', 'property2', dateUpdatedId]
const callback = jest.fn()
const addCard = jest.fn()
const mockStore = configureStore([])
const store = mockStore({
...state,
comments: {
comments: {
[card2Comment.id]: card2Comment,
},
},
contents: {
contents: {
[card2Text.id]: card2Text,
},
},
cards: {
cards: {
[card1.id]: card1,
[card2.id]: card2,
},
},
})
const component = wrapProviders(
<ReduxProvider store={store}>
<Table
boardTree={boardTree!}
board={board}
activeView={view}
visibleGroups={[]}
cards={[card1, card2]}
views={[view]}
selectedCardIds={[]}
readonly={false}
cardIdToFocusOnRender=''
showCard={callback}
addCard={addCard}
onCardClicked={jest.fn()}
/>,
/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
@ -267,7 +326,7 @@ describe('components/table/Table extended', () => {
const board = TestBlockFactory.createBoard()
const createdById = Utils.createGuid()
board.cardProperties.push({
board.fields.cardProperties.push({
id: createdById,
name: 'Created By',
type: 'createdBy',
@ -281,27 +340,20 @@ describe('components/table/Table extended', () => {
card2.createdBy = 'user-id-2'
const view = TestBlockFactory.createBoardView(board)
view.viewType = 'table'
view.groupById = undefined
view.visiblePropertyIds = ['property1', 'property2', createdById]
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, card1, card2, view])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).not.toBeUndefined()
if (!boardTree) {
fail('sync')
}
view.fields.viewType = 'table'
view.fields.groupById = undefined
view.fields.visiblePropertyIds = ['property1', 'property2', createdById]
const callback = jest.fn()
const addCard = jest.fn()
const mockStore = configureStore([])
const store = mockStore({
currentWorkspaceUsers: {
byId: {
'user-id-1': {username: 'username_1'} as IUser,
'user-id-2': {username: 'username_2'} as IUser,
...state,
cards: {
cards: {
[card1.id]: card1,
[card2.id]: card2,
},
},
})
@ -309,7 +361,11 @@ describe('components/table/Table extended', () => {
const component = wrapProviders(
<ReduxProvider store={store}>
<Table
boardTree={boardTree!}
board={board}
activeView={view}
visibleGroups={[]}
cards={[card1, card2]}
views={[view]}
selectedCardIds={[]}
readonly={false}
cardIdToFocusOnRender=''
@ -328,7 +384,7 @@ describe('components/table/Table extended', () => {
const board = TestBlockFactory.createBoard()
const modifiedById = Utils.createGuid()
board.cardProperties.push({
board.fields.cardProperties.push({
id: modifiedById,
name: 'Last Modified By',
type: 'updatedBy',
@ -345,6 +401,8 @@ describe('components/table/Table extended', () => {
card1Text.modifiedBy = 'user-id-4'
card1Text.updateAt = Date.parse('16 Jun 2021 16:22:00')
card1.fields.contentOrder = [card1Text.id]
const card2 = TestBlockFactory.createCard(board)
card2.modifiedBy = 'user-id-2'
card2.updateAt = Date.parse('15 Jun 2021 16:22:00')
@ -356,27 +414,30 @@ describe('components/table/Table extended', () => {
card2.updateAt = Date.parse('16 Jun 2021 16:22:00')
const view = TestBlockFactory.createBoardView(board)
view.viewType = 'table'
view.groupById = undefined
view.visiblePropertyIds = ['property1', 'property2', modifiedById]
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, card1, card2, view, card2Comment, card1Text])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).not.toBeUndefined()
if (!boardTree) {
fail('sync')
}
view.fields.viewType = 'table'
view.fields.groupById = undefined
view.fields.visiblePropertyIds = ['property1', 'property2', modifiedById]
const callback = jest.fn()
const addCard = jest.fn()
const mockStore = configureStore([])
const store = mockStore({
currentWorkspaceUsers: {
byId: {
'user-id-3': {username: 'username_3'} as IUser,
'user-id-4': {username: 'username_4'} as IUser,
...state,
comments: {
comments: {
[card2Comment.id]: card2Comment,
},
},
contents: {
contents: {
[card1Text.id]: card1Text,
},
},
cards: {
cards: {
[card1.id]: card1,
[card2.id]: card2,
},
},
})
@ -384,7 +445,11 @@ describe('components/table/Table extended', () => {
const component = wrapProviders(
<ReduxProvider store={store}>
<Table
boardTree={boardTree!}
board={board}
activeView={view}
visibleGroups={[]}
cards={[card1, card2]}
views={[view]}
selectedCardIds={[]}
readonly={false}
cardIdToFocusOnRender=''

View File

@ -1,33 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef, useState} from 'react'
import React, {useCallback} from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import {useDragLayer, useDrop} from 'react-dnd'
import {IPropertyOption, IPropertyTemplate} from '../../blocks/board'
import {MutableBoardView} from '../../blocks/boardView'
import {IPropertyOption, IPropertyTemplate, Board, BoardGroup} from '../../blocks/board'
import {createBoardView, BoardView, ISortOption} from '../../blocks/boardView'
import {Card} from '../../blocks/card'
import {Constants} from '../../constants'
import mutator from '../../mutator'
import {Utils} from '../../utils'
import {BoardTree} from '../../viewModel/boardTree'
import {useAppDispatch} from '../../store/hooks'
import {updateView} from '../../store/views'
import {OctoUtils} from '../../octoUtils'
import './table.scss'
import {CardTree, MutableCardTree} from '../../viewModel/cardTree'
import useCardListener from '../../hooks/cardListener'
import TableHeader from './tableHeader'
import TableRows from './tableRows'
import TableGroup from './tableGroup'
type Props = {
boardTree: BoardTree
selectedCardIds: string[]
board: Board
cards: Card[]
activeView: BoardView
views: BoardView[]
visibleGroups: BoardGroup[]
groupByProperty?: IPropertyTemplate
readonly: boolean
cardIdToFocusOnRender: string
showCard: (cardId?: string) => void
@ -35,38 +37,11 @@ type Props = {
onCardClicked: (e: React.MouseEvent, card: Card) => void
}
const Table = (props: Props) => {
const {boardTree} = props
const {board, cards, activeView, visibleGroups} = boardTree
const isManualSort = activeView.sortOptions.length === 0
const Table = (props: Props): JSX.Element => {
const {board, cards, activeView, visibleGroups, groupByProperty, views} = props
const isManualSort = activeView.fields.sortOptions?.length === 0
const intl = useIntl()
const [cardTrees, setCardTrees] = useState<{[key: string]: CardTree | undefined}>({a: undefined})
const cardTreeRef = useRef<{[key: string]: CardTree | undefined}>()
cardTreeRef.current = cardTrees
useCardListener(
async (blocks) => {
for (const block of blocks) {
const cardTree = cardTreeRef.current && cardTreeRef.current[block.parentId]
if (cardTree) {
const newCardTree = MutableCardTree.incrementalUpdate(cardTree, blocks)
setCardTrees((oldTree) => ({...oldTree, [block.parentId]: newCardTree}))
} else {
MutableCardTree.sync(block.parentId).
then((newCardTree) => {
setCardTrees((oldTree) => ({...oldTree, [block.parentId]: newCardTree}))
})
}
}
},
async () => {
cards.forEach(async (c) => {
const newCardTree = await MutableCardTree.sync(c.id)
setCardTrees((oldTree) => ({...oldTree, [c.id]: newCardTree}))
})
},
)
const dispatch = useAppDispatch()
const {offset, resizingColumn} = useDragLayer((monitor) => {
if (monitor.getItemType() === 'horizontalGrip') {
@ -85,23 +60,28 @@ const Table = (props: Props) => {
const [, drop] = useDrop(() => ({
accept: 'horizontalGrip',
drop: (item: { id: string }, monitor) => {
const columnWidths = {...activeView.columnWidths}
drop: async (item: { id: string }, monitor) => {
const columnWidths = {...activeView.fields.columnWidths}
const finalOffset = monitor.getDifferenceFromInitialOffset()?.x || 0
const newWidth = Math.max(Constants.minColumnWidth, (columnWidths[item.id] || 0) + (finalOffset || 0))
if (newWidth !== columnWidths[item.id]) {
columnWidths[item.id] = newWidth
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
const newView = createBoardView(activeView)
newView.fields.columnWidths = columnWidths
try {
dispatch(updateView(newView))
await mutator.updateBlock(newView, activeView, 'resize column')
} catch {
dispatch(updateView(activeView))
}
}
},
}), [activeView])
const onAutoSizeColumn = ((columnID: string, headerWidth: number) => {
const onAutoSizeColumn = useCallback((columnID: string, headerWidth: number) => {
let longestSize = headerWidth
const visibleProperties = board.cardProperties.filter(() => activeView.visiblePropertyIds.includes(columnID))
const visibleProperties = board.fields.cardProperties.filter(() => activeView.fields.visiblePropertyIds.includes(columnID)) || []
const columnRef = columnRefs.get(columnID)
if (!columnRef?.current) {
return
@ -111,11 +91,11 @@ const Table = (props: Props) => {
cards.forEach((card) => {
let displayValue = card.title
if (columnID !== Constants.titleColumnId) {
const template = visibleProperties.find((t) => t.id === columnID)
const template = visibleProperties.find((t: IPropertyTemplate) => t.id === columnID)
if (!template) {
return
}
displayValue = (OctoUtils.propertyDisplayValue(card, card.properties[columnID], template, intl) || '') as string
displayValue = (OctoUtils.propertyDisplayValue(card, card.fields.properties[columnID], template, intl) || '') as string
if (template.type === 'select') {
displayValue = displayValue.toUpperCase()
}
@ -126,43 +106,43 @@ const Table = (props: Props) => {
}
})
const columnWidths = {...activeView.columnWidths}
const columnWidths = {...activeView.fields.columnWidths}
columnWidths[columnID] = longestSize
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
const newView = createBoardView(activeView)
newView.fields.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'autosize column')
})
}, [activeView, board, cards])
const hideGroup = (groupById: string): void => {
const index: number = activeView.collapsedOptionIds.indexOf(groupById)
const newValue: string[] = [...activeView.collapsedOptionIds]
const hideGroup = useCallback((groupById: string): void => {
const index: number = activeView.fields.collapsedOptionIds.indexOf(groupById)
const newValue: string[] = [...activeView.fields.collapsedOptionIds]
if (index > -1) {
newValue.splice(index, 1)
} else if (groupById !== '') {
newValue.push(groupById)
}
const newView = new MutableBoardView(activeView)
newView.collapsedOptionIds = newValue
const newView = createBoardView(activeView)
newView.fields.collapsedOptionIds = newValue
mutator.performAsUndoGroup(async () => {
await mutator.updateBlock(newView, activeView, 'hide group')
})
}
}, [activeView])
const onDropToColumn = async (template: IPropertyTemplate, container: IPropertyTemplate) => {
const onDropToColumn = useCallback(async (template: IPropertyTemplate, container: IPropertyTemplate) => {
Utils.log(`ondrop. Source column: ${template.name}, dest column: ${container.name}`)
// Move template to new index
const destIndex = container ? board.cardProperties.indexOf(container) : 0
const destIndex = container ? board.fields.cardProperties.indexOf(container) : 0
await mutator.changePropertyTemplateOrder(board, template, destIndex >= 0 ? destIndex : 0)
}
}, [board])
const onDropToGroupHeader = async (option: IPropertyOption, dstOption?: IPropertyOption) => {
const onDropToGroupHeader = useCallback(async (option: IPropertyOption, dstOption?: IPropertyOption) => {
if (dstOption) {
Utils.log(`ondrop. Header target: ${dstOption.value}, source: ${option?.value}`)
// Move option to new index
const visibleOptionIds = boardTree.visibleGroups.map((o) => o.option.id)
const visibleOptionIds = visibleGroups.map((o) => o.option.id)
const srcIndex = visibleOptionIds.indexOf(dstOption.id)
const destIndex = visibleOptionIds.indexOf(option.id)
@ -171,23 +151,22 @@ const Table = (props: Props) => {
await mutator.changeViewVisibleOptionIds(activeView, visibleOptionIds)
}
}
}, [activeView, visibleGroups])
const onDropToCard = (srcCard: Card, dstCard: Card) => {
const onDropToCard = useCallback((srcCard: Card, dstCard: Card) => {
Utils.log(`onDropToCard: ${dstCard.title}`)
onDropToGroup(srcCard, dstCard.properties[activeView.groupById!] as string, dstCard.id)
}
onDropToGroup(srcCard, dstCard.fields.properties[activeView.fields.groupById!] as string, dstCard.id)
}, [activeView])
const onDropToGroup = (srcCard: Card, groupID: string, dstCardID: string) => {
const onDropToGroup = useCallback((srcCard: Card, groupID: string, dstCardID: string) => {
Utils.log(`onDropToGroup: ${srcCard.title}`)
const {selectedCardIds} = props
const draggedCardIds = Array.from(new Set(selectedCardIds).add(srcCard.id))
const description = draggedCardIds.length > 1 ? `drag ${draggedCardIds.length} cards` : 'drag card'
if (activeView.groupById !== undefined) {
const orderedCards = boardTree.orderedCards()
const cardsById: { [key: string]: Card } = orderedCards.reduce((acc: { [key: string]: Card }, card: Card): { [key: string]: Card } => {
if (activeView.fields.groupById !== undefined) {
const cardsById: { [key: string]: Card } = cards.reduce((acc: { [key: string]: Card }, card: Card): { [key: string]: Card } => {
acc[card.id] = card
return acc
}, {})
@ -197,13 +176,13 @@ const Table = (props: Props) => {
// Update properties of dragged cards
const awaits = []
for (const draggedCard of draggedCards) {
Utils.log(`draggedCard: ${draggedCard.title}, column: ${draggedCard.properties}`)
Utils.log(`draggedCard: ${draggedCard.title}, column: ${draggedCard.fields.properties}`)
Utils.log(`droppedColumn: ${groupID}`)
const oldOptionId = draggedCard.properties[boardTree.groupByProperty!.id]
const oldOptionId = draggedCard.fields.properties[groupByProperty!.id]
Utils.log(`ondrop. oldValue: ${oldOptionId}`)
if (groupID !== oldOptionId) {
awaits.push(mutator.changePropertyValue(draggedCard, boardTree.groupByProperty!.id, groupID, description))
awaits.push(mutator.changePropertyValue(draggedCard, groupByProperty!.id, groupID, description))
}
}
await Promise.all(awaits)
@ -212,7 +191,7 @@ const Table = (props: Props) => {
// Update dstCard order
if (isManualSort) {
let cardOrder = Array.from(new Set([...activeView.cardOrder, ...boardTree.cards.map((o) => o.id)]))
let cardOrder = Array.from(new Set([...activeView.fields.cardOrder, ...cards.map((o) => o.id)]))
if (dstCardID) {
const isDraggingDown = cardOrder.indexOf(srcCard.id) <= cardOrder.indexOf(dstCardID)
cardOrder = cardOrder.filter((id) => !draggedCardIds.includes(id))
@ -223,7 +202,7 @@ const Table = (props: Props) => {
cardOrder.splice(destIndex, 0, ...draggedCardIds)
} else {
// Find index of first group item
const firstCard = boardTree.orderedCards().find((card) => card.properties[activeView.groupById!] === groupID)
const firstCard = cards.find((card) => card.fields.properties[activeView.fields.groupById!] === groupID)
if (firstCard) {
const destIndex = cardOrder.indexOf(firstCard.id)
cardOrder.splice(destIndex, 0, ...draggedCardIds)
@ -237,13 +216,13 @@ const Table = (props: Props) => {
await mutator.changeViewCardOrder(activeView, cardOrder, description)
})
}
}
}, [activeView, cards, props.selectedCardIds, groupByProperty])
const propertyNameChanged = async (option: IPropertyOption, text: string): Promise<void> => {
await mutator.changePropertyOptionValue(boardTree, boardTree.groupByProperty!, option, text)
}
const propertyNameChanged = useCallback(async (option: IPropertyOption, text: string): Promise<void> => {
await mutator.changePropertyOptionValue(board, groupByProperty!, option, text)
}, [board, groupByProperty])
const titleSortOption = activeView.sortOptions.find((o) => o.propertyId === Constants.titleColumnId)
const titleSortOption = activeView.fields.sortOptions?.find((o) => o.propertyId === Constants.titleColumnId)
let titleSorted: 'up' | 'down' | 'none' = 'none'
if (titleSortOption) {
titleSorted = titleSortOption.reversed ? 'down' : 'up'
@ -269,7 +248,10 @@ const Table = (props: Props) => {
}
sorted={titleSorted}
readonly={props.readonly}
boardTree={boardTree}
board={board}
activeView={activeView}
cards={cards}
views={views}
template={{id: Constants.titleColumnId, name: 'title', type: 'text', options: []}}
offset={resizingColumn === Constants.titleColumnId ? offset : 0}
onDrop={onDropToColumn}
@ -278,9 +260,9 @@ const Table = (props: Props) => {
{/* Table header row */}
{board.cardProperties.filter((template) => activeView.visiblePropertyIds.includes(template.id)).map((template) => {
{board.fields.cardProperties.filter((template: IPropertyTemplate) => activeView.fields.visiblePropertyIds.includes(template.id)).map((template: IPropertyTemplate) => {
let sorted: 'up' | 'down' | 'none' = 'none'
const sortOption = activeView.sortOptions.find((o) => o.propertyId === template.id)
const sortOption = activeView.fields.sortOptions.find((o: ISortOption) => o.propertyId === template.id)
if (sortOption) {
sorted = sortOption.reversed ? 'down' : 'up'
}
@ -290,7 +272,10 @@ const Table = (props: Props) => {
name={template.name}
sorted={sorted}
readonly={props.readonly}
boardTree={boardTree}
board={board}
activeView={activeView}
cards={cards}
views={views}
template={template}
key={template.id}
offset={resizingColumn === template.id ? offset : 0}
@ -303,13 +288,14 @@ const Table = (props: Props) => {
{/* Table header row */}
<div className='table-row-container'>
{activeView.groupById &&
{activeView.fields.groupById &&
visibleGroups.map((group) => {
return (
<TableGroup
key={group.option.id}
boardTree={boardTree}
cardTrees={cardTrees}
board={board}
activeView={activeView}
groupByProperty={groupByProperty}
group={group}
readonly={props.readonly}
columnRefs={columnRefs}
@ -328,12 +314,12 @@ const Table = (props: Props) => {
}
{/* No Grouping, Rows, one per card */}
{!activeView.groupById &&
{!activeView.fields.groupById &&
<TableRows
boardTree={boardTree}
cardTrees={cardTrees}
board={board}
activeView={activeView}
columnRefs={columnRefs}
cards={boardTree.cards}
cards={cards}
selectedCardIds={props.selectedCardIds}
readonly={props.readonly}
cardIdToFocusOnRender={props.cardIdToFocusOnRender}
@ -347,7 +333,7 @@ const Table = (props: Props) => {
{/* Add New row */}
<div className='octo-table-footer'>
{!props.readonly && !activeView.groupById &&
{!props.readonly && !activeView.fields.groupById &&
<div
className='octo-table-cell'
onClick={() => {

View File

@ -5,19 +5,18 @@ import React from 'react'
import {useDrop} from 'react-dnd'
import {IPropertyOption} from '../../blocks/board'
import {Board, IPropertyOption, IPropertyTemplate, BoardGroup} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import {Card} from '../../blocks/card'
import {BoardTree, BoardTreeGroup} from '../../viewModel/boardTree'
import {CardTree} from '../../viewModel/cardTree'
import TableGroupHeaderRow from './tableGroupHeaderRow'
import TableRows from './tableRows'
type Props = {
boardTree: BoardTree
cardTrees: { [key: string]: CardTree | undefined }
group: BoardTreeGroup
board: Board
activeView: BoardView
groupByProperty?: IPropertyTemplate
group: BoardGroup
readonly: boolean
columnRefs: Map<string, React.RefObject<HTMLDivElement>>
selectedCardIds: string[]
@ -33,7 +32,7 @@ type Props = {
}
const TableGroup = React.memo((props: Props): JSX.Element => {
const {boardTree, group, onDropToGroup} = props
const {board, activeView, group, onDropToGroup, groupByProperty} = props
const groupId = group.option.id
const [{isOver}, drop] = useDrop(() => ({
@ -61,7 +60,9 @@ const TableGroup = React.memo((props: Props): JSX.Element => {
>
<TableGroupHeaderRow
group={group}
boardTree={boardTree}
board={board}
activeView={activeView}
groupByProperty={groupByProperty}
hideGroup={props.hideGroup}
addCard={props.addCard}
readonly={props.readonly}
@ -71,8 +72,8 @@ const TableGroup = React.memo((props: Props): JSX.Element => {
{(group.cards.length > 0) &&
<TableRows
boardTree={boardTree}
cardTrees={props.cardTrees}
board={board}
activeView={activeView}
columnRefs={props.columnRefs}
cards={group.cards}
selectedCardIds={props.selectedCardIds}

View File

@ -16,17 +16,9 @@ import {act} from 'react-dom/test-utils'
import userEvent from '@testing-library/user-event'
import {TestBlockFactory} from '../../test/testBlockFactory'
import {FetchMock} from '../../test/fetchMock'
import {MutableBoardTree} from '../../viewModel/boardTree'
import TableGroupHeaderRowElement from './tableGroupHeaderRow'
global.fetch = FetchMock.fn
beforeEach(() => {
FetchMock.fn.mockReset()
})
const wrapProviders = (children: any) => {
return (
<DndProvider backend={HTML5Backend}>
@ -39,11 +31,7 @@ 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
view2.fields.sortOptions = []
const boardTreeNoGroup = {
option: {
@ -64,21 +52,22 @@ const boardTreeGroup = {
}
test('should match snapshot, no groups', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const component = wrapProviders(
<TableGroupHeaderRowElement
boardTree={boardTree!}
board={board}
activeView={view}
group={boardTreeNoGroup}
readonly={false}
hideGroup={jest.fn()}
addCard={jest.fn()}
propertyNameChanged={jest.fn()}
onDrop={jest.fn()}
groupByProperty={{
id: '',
name: 'Property 1',
type: 'text',
options: [{id: 'property1', value: 'Property 1', color: ''}],
}}
/>,
)
const {container} = render(component)
@ -86,16 +75,10 @@ test('should match snapshot, no groups', async () => {
})
test('should match snapshot with Group', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const component = wrapProviders(
<TableGroupHeaderRowElement
boardTree={boardTree!}
board={board}
activeView={view}
group={boardTreeGroup}
readonly={false}
hideGroup={jest.fn()}
@ -109,16 +92,10 @@ test('should match snapshot with Group', async () => {
})
test('should match snapshot on read only', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const component = wrapProviders(
<TableGroupHeaderRowElement
boardTree={boardTree!}
board={board}
activeView={view}
group={boardTreeGroup}
readonly={true}
hideGroup={jest.fn()}
@ -132,19 +109,15 @@ test('should match snapshot on read only', async () => {
})
test('should match snapshot, hide group', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const hideGroup = jest.fn()
view.collapsedOptionIds = [boardTreeGroup.option.id]
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const collapsedOptionsView = TestBlockFactory.createBoardView(board)
collapsedOptionsView.fields.collapsedOptionIds = [boardTreeGroup.option.id]
const component = wrapProviders(
<TableGroupHeaderRowElement
boardTree={boardTree!}
board={board}
activeView={collapsedOptionsView}
group={boardTreeGroup}
readonly={false}
hideGroup={hideGroup}
@ -166,18 +139,12 @@ test('should match snapshot, hide group', async () => {
})
test('should match snapshot, add new', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const addNew = jest.fn()
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const component = wrapProviders(
<TableGroupHeaderRowElement
boardTree={boardTree!}
board={board}
activeView={view}
group={boardTreeGroup}
readonly={false}
hideGroup={jest.fn()}
@ -200,16 +167,10 @@ test('should match snapshot, add new', async () => {
})
test('should match snapshot, edit title', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const component = wrapProviders(
<TableGroupHeaderRowElement
boardTree={boardTree!}
board={board}
activeView={view}
group={boardTreeGroup}
readonly={false}
hideGroup={jest.fn()}

View File

@ -5,10 +5,10 @@ import React, {useState, useEffect} from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import {Constants} from '../../constants'
import {IPropertyOption} from '../../blocks/board'
import {IPropertyOption, Board, IPropertyTemplate, BoardGroup} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import {useSortable} from '../../hooks/sortable'
import mutator from '../../mutator'
import {BoardTree, BoardTreeGroup} from '../../viewModel/boardTree'
import Button from '../../widgets/buttons/button'
import IconButton from '../../widgets/buttons/iconButton'
import AddIcon from '../../widgets/icons/add'
@ -22,8 +22,10 @@ import Editable from '../../widgets/editable'
import Label from '../../widgets/label'
type Props = {
boardTree: BoardTree
group: BoardTreeGroup
board: Board
activeView: BoardView
group: BoardGroup
groupByProperty?: IPropertyTemplate
readonly: boolean
hideGroup: (groupByOptionId: string) => void
addCard: (groupByOptionId?: string) => Promise<void>
@ -32,8 +34,7 @@ type Props = {
}
const TableGroupHeaderRow = React.memo((props: Props): JSX.Element => {
const {boardTree, group} = props
const {activeView} = boardTree
const {board, activeView, group, groupByProperty} = props
const [groupTitle, setGroupTitle] = useState(group.option.value)
const [isDragging, isOver, groupHeaderRef] = useSortable('groupHeader', group.option, !props.readonly, props.onDrop)
@ -46,12 +47,12 @@ const TableGroupHeaderRow = React.memo((props: Props): JSX.Element => {
if (isOver) {
className += ' dragover'
}
if (activeView.collapsedOptionIds.indexOf(group.option.id || 'undefined') < 0) {
if (activeView.fields.collapsedOptionIds.indexOf(group.option.id || 'undefined') < 0) {
className += ' expanded'
}
const columnWidth = (templateId: string): number => {
return Math.max(Constants.minColumnWidth, props.boardTree.activeView.columnWidths[templateId] || 0)
return Math.max(Constants.minColumnWidth, props.activeView.fields.columnWidths[templateId] || 0)
}
return (
@ -76,13 +77,13 @@ const TableGroupHeaderRow = React.memo((props: Props): JSX.Element => {
title={intl.formatMessage({
id: 'BoardComponent.no-property-title',
defaultMessage: 'Items with an empty {property} property will go here. This column cannot be removed.',
}, {property: boardTree.groupByProperty!.name})}
}, {property: groupByProperty?.name})}
>
<FormattedMessage
id='BoardComponent.no-property'
defaultMessage='No {property}'
values={{
property: boardTree.groupByProperty!.name,
property: groupByProperty?.name,
}}
/>
</Label>}
@ -124,7 +125,7 @@ const TableGroupHeaderRow = React.memo((props: Props): JSX.Element => {
id='delete'
icon={<DeleteIcon/>}
name={intl.formatMessage({id: 'BoardComponent.delete', defaultMessage: 'Delete'})}
onClick={() => mutator.deletePropertyOption(boardTree, boardTree.groupByProperty!, group.option)}
onClick={() => mutator.deletePropertyOption(board, groupByProperty!, group.option)}
/>
<Menu.Separator/>
{Object.entries(Constants.menuColors).map(([key, color]) => (
@ -132,7 +133,7 @@ const TableGroupHeaderRow = React.memo((props: Props): JSX.Element => {
key={key}
id={key}
name={color}
onClick={() => mutator.changePropertyOptionColor(boardTree.board, boardTree.groupByProperty!, group.option, key)}
onClick={() => mutator.changePropertyOptionColor(board, groupByProperty!, group.option, key)}
/>
))}
</>}

View File

@ -11,17 +11,9 @@ import {DndProvider} from 'react-dnd'
import {HTML5Backend} from 'react-dnd-html5-backend'
import {TestBlockFactory} from '../../test/testBlockFactory'
import {FetchMock} from '../../test/fetchMock'
import {MutableBoardTree} from '../../viewModel/boardTree'
import TableHeader from './tableHeader'
global.fetch = FetchMock.fn
beforeEach(() => {
FetchMock.fn.mockReset()
})
const wrapProviders = (children: any) => {
return (
<DndProvider backend={HTML5Backend}>
@ -35,27 +27,20 @@ describe('components/table/TableHeaderMenu', () => {
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
view2.fields.sortOptions = []
test('should match snapshot, title column', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const onAutoSizeColumn = jest.fn()
const component = wrapProviders(
<TableHeader
readonly={false}
sorted={'none'}
name={'my Name'}
boardTree={boardTree!}
template={board.cardProperties[0]}
board={board}
activeView={view}
cards={[]}
views={[view, view2]}
template={board.fields.cardProperties[0]}
offset={0}
onDrop={jest.fn()}
onAutoSizeColumn={onAutoSizeColumn}

View File

@ -2,9 +2,10 @@
// See LICENSE.txt for license information.
import React from 'react'
import {IPropertyTemplate} from '../../blocks/board'
import {Board, IPropertyTemplate} from '../../blocks/board'
import {Constants} from '../../constants'
import {BoardTree} from '../../viewModel/boardTree'
import {Card} from '../../blocks/card'
import {BoardView} from '../../blocks/boardView'
import SortDownIcon from '../../widgets/icons/sortDown'
import SortUpIcon from '../../widgets/icons/sortUp'
import MenuWrapper from '../../widgets/menuWrapper'
@ -21,7 +22,10 @@ type Props = {
readonly: boolean
sorted: 'up'|'down'|'none'
name: React.ReactNode
boardTree: BoardTree
board: Board
activeView: BoardView
cards: Card[]
views: BoardView[]
template: IPropertyTemplate
offset: number
onDrop: (template: IPropertyTemplate, container: IPropertyTemplate) => void
@ -32,7 +36,7 @@ const TableHeader = React.memo((props: Props): JSX.Element => {
const [isDragging, isOver, columnRef] = useSortable('column', props.template, !props.readonly, props.onDrop)
const columnWidth = (templateId: string): number => {
return Math.max(Constants.minColumnWidth, (props.boardTree.activeView.columnWidths[templateId] || 0) + props.offset)
return Math.max(Constants.minColumnWidth, (props.activeView.fields.columnWidths[templateId] || 0) + props.offset)
}
const onAutoSizeColumn = (templateId: string) => {
@ -63,7 +67,10 @@ const TableHeader = React.memo((props: Props): JSX.Element => {
{props.sorted === 'down' && <SortDownIcon/>}
</Label>
<TableHeaderMenu
boardTree={props.boardTree}
board={props.board}
activeView={props.activeView}
views={props.views}
cards={props.cards}
templateId={props.template.id}
/>
</MenuWrapper>

View File

@ -13,7 +13,6 @@ import mutator from '../../mutator'
import {TestBlockFactory} from '../../test/testBlockFactory'
import {FetchMock} from '../../test/fetchMock'
import {MutableBoardTree} from '../../viewModel/boardTree'
import TableHeaderMenu from './tableHeaderMenu'
@ -41,23 +40,16 @@ describe('components/table/TableHeaderMenu', () => {
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
view2.fields.sortOptions = []
test('should match snapshot, title column', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const component = wrapIntl(
<TableHeaderMenu
templateId={Constants.titleColumnId}
boardTree={boardTree!}
board={board}
activeView={view}
views={[view, view2]}
cards={[]}
/>,
)
const {container, getByText} = render(component)
@ -78,16 +70,13 @@ describe('components/table/TableHeaderMenu', () => {
})
test('should match snapshot, other column', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const component = wrapIntl(
<TableHeaderMenu
templateId={'property 1'}
boardTree={boardTree!}
board={board}
activeView={view}
views={[view, view2]}
cards={[]}
/>,
)
const {container, getByText} = render(component)

View File

@ -5,18 +5,22 @@ import React, {FC} from 'react'
import {useIntl} from 'react-intl'
import {Constants} from '../../constants'
import {Board, IPropertyTemplate} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import {Card} from '../../blocks/card'
import mutator from '../../mutator'
import {BoardTree} from '../../viewModel/boardTree'
import Menu from '../../widgets/menu'
type Props = {
templateId: string
boardTree: BoardTree
board: Board
activeView: BoardView
views: BoardView[]
cards: Card[]
}
const TableHeaderMenu: FC<Props> = (props: Props): JSX.Element => {
const {boardTree, templateId} = props
const {board, activeView} = boardTree
const {board, activeView, templateId, views, cards} = props
const intl = useIntl()
return (
<Menu>
@ -37,8 +41,8 @@ const TableHeaderMenu: FC<Props> = (props: Props): JSX.Element => {
if (props.templateId === Constants.titleColumnId) {
// TODO: Handle name column
} else {
const index = board.cardProperties.findIndex((o) => o.id === templateId)
mutator.insertPropertyTemplate(boardTree, index)
const index = board.fields.cardProperties.findIndex((o: IPropertyTemplate) => o.id === templateId)
mutator.insertPropertyTemplate(board, activeView, index)
}
}}
/>
@ -49,8 +53,8 @@ const TableHeaderMenu: FC<Props> = (props: Props): JSX.Element => {
if (templateId === Constants.titleColumnId) {
// TODO: Handle title column
} else {
const index = board.cardProperties.findIndex((o) => o.id === templateId) + 1
mutator.insertPropertyTemplate(boardTree, index)
const index = board.fields.cardProperties.findIndex((o: IPropertyTemplate) => o.id === templateId) + 1
mutator.insertPropertyTemplate(board, activeView, index)
}
}}
/>
@ -59,17 +63,17 @@ const TableHeaderMenu: FC<Props> = (props: Props): JSX.Element => {
<Menu.Text
id='hide'
name={intl.formatMessage({id: 'TableHeaderMenu.hide', defaultMessage: 'Hide'})}
onClick={() => mutator.changeViewVisibleProperties(activeView, activeView.visiblePropertyIds.filter((o) => o !== templateId))}
onClick={() => mutator.changeViewVisibleProperties(activeView, activeView.fields.visiblePropertyIds.filter((o: string) => o !== templateId))}
/>
<Menu.Text
id='duplicate'
name={intl.formatMessage({id: 'TableHeaderMenu.duplicate', defaultMessage: 'Duplicate'})}
onClick={() => mutator.duplicatePropertyTemplate(boardTree, templateId)}
onClick={() => mutator.duplicatePropertyTemplate(board, activeView, templateId)}
/>
<Menu.Text
id='delete'
name={intl.formatMessage({id: 'TableHeaderMenu.delete', defaultMessage: 'Delete'})}
onClick={() => mutator.deleteProperty(boardTree, templateId)}
onClick={() => mutator.deleteProperty(board, views, cards, templateId)}
/>
</>}
</Menu>

View File

@ -2,7 +2,9 @@
// See LICENSE.txt for license information.
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import '@testing-library/jest-dom'
import {IntlProvider} from 'react-intl'
@ -12,19 +14,9 @@ import {DndProvider} from 'react-dnd'
import {HTML5Backend} from 'react-dnd-html5-backend'
import {TestBlockFactory} from '../../test/testBlockFactory'
import {FetchMock} from '../../test/fetchMock'
import {MutableBoardTree} from '../../viewModel/boardTree'
import {MutableCardTree} from '../../viewModel/cardTree'
import TableRow from './tableRow'
global.fetch = FetchMock.fn
beforeEach(() => {
FetchMock.fn.mockReset()
})
const wrapProviders = (children: any) => {
return (
<DndProvider backend={HTML5Backend}>
@ -38,26 +30,36 @@ describe('components/table/TableRow', () => {
const view = TestBlockFactory.createBoardView(board)
const view2 = TestBlockFactory.createBoardView(board)
view2.sortOptions = []
view2.fields.sortOptions = []
const card = TestBlockFactory.createCard(board)
const cardTemplate = TestBlockFactory.createCard(board)
cardTemplate.isTemplate = true
cardTemplate.fields.isTemplate = true
const state = {
users: {},
comments: {
comments: {},
},
contents: {
contents: {},
},
cards: {
cards: {
[card.id]: card,
},
},
}
const mockStore = configureStore([])
test('should match snapshot', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const cardTree = new MutableCardTree(card)
const store = mockStore(state)
const component = wrapProviders(
<ReduxProvider store={store}>
<TableRow
boardTree={boardTree!}
cardTree={cardTree}
board={board}
activeView={view}
card={card}
isSelected={false}
focusOnMount={false}
@ -69,27 +71,21 @@ describe('components/table/TableRow', () => {
resizingColumn={''}
columnRefs={new Map()}
onDrop={jest.fn()}
/>,
/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('should match snapshot, read-only', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const cardTree = new MutableCardTree(card)
const store = mockStore(state)
const component = wrapProviders(
<ReduxProvider store={store}>
<TableRow
boardTree={boardTree!}
cardTree={cardTree}
board={board}
card={card}
activeView={view}
isSelected={false}
focusOnMount={false}
onSaveWithEnter={jest.fn()}
@ -100,27 +96,21 @@ describe('components/table/TableRow', () => {
resizingColumn={''}
columnRefs={new Map()}
onDrop={jest.fn()}
/>,
/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('should match snapshot, isSelected', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const cardTree = new MutableCardTree(card)
const store = mockStore(state)
const component = wrapProviders(
<ReduxProvider store={store}>
<TableRow
boardTree={boardTree!}
cardTree={cardTree}
board={board}
card={card}
activeView={view}
isSelected={true}
focusOnMount={false}
onSaveWithEnter={jest.fn()}
@ -131,29 +121,24 @@ describe('components/table/TableRow', () => {
resizingColumn={''}
columnRefs={new Map()}
onDrop={jest.fn()}
/>,
/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('should match snapshot, collapsed tree', async () => {
// Sync
view.collapsedOptionIds = ['value1']
view.hiddenOptionIds = []
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const cardTree = new MutableCardTree(card)
view.fields.collapsedOptionIds = ['value1']
view.fields.hiddenOptionIds = []
const store = mockStore(state)
const component = wrapProviders(
<ReduxProvider store={store}>
<TableRow
boardTree={boardTree!}
cardTree={cardTree}
board={board}
card={card}
activeView={view}
isSelected={false}
focusOnMount={false}
onSaveWithEnter={jest.fn()}
@ -164,27 +149,23 @@ describe('components/table/TableRow', () => {
resizingColumn={''}
columnRefs={new Map()}
onDrop={jest.fn()}
/>,
/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('should match snapshot, display properties', async () => {
// Sync
view.visiblePropertyIds = ['property1', 'property2']
view.fields.visiblePropertyIds = ['property1', 'property2']
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const cardTree = new MutableCardTree(card)
const store = mockStore(state)
const component = wrapProviders(
<ReduxProvider store={store}>
<TableRow
boardTree={boardTree!}
cardTree={cardTree}
board={board}
card={card}
activeView={view}
isSelected={false}
focusOnMount={false}
onSaveWithEnter={jest.fn()}
@ -194,27 +175,23 @@ describe('components/table/TableRow', () => {
resizingColumn={''}
columnRefs={new Map()}
onDrop={jest.fn()}
/>,
/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('should match snapshot, resizing column', async () => {
// Sync
view.visiblePropertyIds = ['property1', 'property2']
view.fields.visiblePropertyIds = ['property1', 'property2']
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const cardTree = new MutableCardTree(card)
const store = mockStore(state)
const component = wrapProviders(
<ReduxProvider store={store}>
<TableRow
boardTree={boardTree!}
cardTree={cardTree}
board={board}
card={card}
activeView={view}
isSelected={false}
focusOnMount={false}
onSaveWithEnter={jest.fn()}
@ -224,7 +201,8 @@ describe('components/table/TableRow', () => {
resizingColumn={'property1'}
columnRefs={new Map()}
onDrop={jest.fn()}
/>,
/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()

View File

@ -4,21 +4,24 @@ import React, {useState, useRef, useEffect} from 'react'
import {FormattedMessage} from 'react-intl'
import {Card} from '../../blocks/card'
import {Board, IPropertyTemplate} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import {Constants} from '../../constants'
import mutator from '../../mutator'
import {BoardTree} from '../../viewModel/boardTree'
import Button from '../../widgets/buttons/button'
import Editable from '../../widgets/editable'
import {useSortable} from '../../hooks/sortable'
import {useAppSelector} from '../../store/hooks'
import {getCardContents} from '../../store/contents'
import {getCardComments} from '../../store/comments'
import PropertyValueElement from '../propertyValueElement'
import './tableRow.scss'
import {CardTree} from '../../viewModel/cardTree'
type Props = {
boardTree: BoardTree
board: Board
activeView: BoardView
card: Card
cardTree?: CardTree
isSelected: boolean
focusOnMount: boolean
onSaveWithEnter: () => void
@ -32,14 +35,14 @@ type Props = {
}
const TableRow = React.memo((props: Props) => {
const {boardTree, onSaveWithEnter, columnRefs} = props
const {board, activeView} = boardTree
const {board, activeView, onSaveWithEnter, columnRefs, card} = props
const contents = useAppSelector(getCardContents(card.id || ''))
const comments = useAppSelector(getCardComments(card.id))
const titleRef = useRef<{focus(selectAll?: boolean): void}>(null)
const [title, setTitle] = useState(props.card.title)
const {card} = props
const isManualSort = activeView.sortOptions.length === 0
const isGrouped = Boolean(activeView.groupById)
const [title, setTitle] = useState(props.card.title || '')
const isManualSort = activeView.fields.sortOptions.length === 0
const isGrouped = Boolean(activeView.fields.groupById)
const [isDragging, isOver, cardRef] = useSortable('card', card, !props.readonly && (isManualSort || isGrouped), props.onDrop)
useEffect(() => {
@ -50,9 +53,9 @@ const TableRow = React.memo((props: Props) => {
const columnWidth = (templateId: string): number => {
if (props.resizingColumn === templateId) {
return Math.max(Constants.minColumnWidth, (props.boardTree.activeView.columnWidths[templateId] || 0) + props.offset)
return Math.max(Constants.minColumnWidth, (props.activeView.fields.columnWidths[templateId] || 0) + props.offset)
}
return Math.max(Constants.minColumnWidth, props.boardTree.activeView.columnWidths[templateId] || 0)
return Math.max(Constants.minColumnWidth, props.activeView.fields.columnWidths[templateId] || 0)
}
let className = props.isSelected ? 'TableRow octo-table-row selected' : 'TableRow octo-table-row'
@ -60,9 +63,9 @@ const TableRow = React.memo((props: Props) => {
className += ' dragover'
}
if (isGrouped) {
const groupID = activeView.groupById || ''
const groupValue = card.properties[groupID] as string || 'undefined'
if (activeView.collapsedOptionIds.indexOf(groupValue) > -1) {
const groupID = activeView.fields.groupById || ''
const groupValue = card.fields.properties[groupID] as string || 'undefined'
if (activeView.fields.collapsedOptionIds.indexOf(groupValue) > -1) {
className += ' hidden'
}
}
@ -87,7 +90,7 @@ const TableRow = React.memo((props: Props) => {
ref={columnRefs.get(Constants.titleColumnId)}
>
<div className='octo-icontitle'>
<div className='octo-icon'>{card.icon}</div>
<div className='octo-icon'>{card.fields.icon}</div>
<Editable
ref={titleRef}
value={title}
@ -99,14 +102,14 @@ const TableRow = React.memo((props: Props) => {
onSaveWithEnter()
}
}}
onCancel={() => setTitle(card.title)}
onCancel={() => setTitle(card.title || '')}
readonly={props.readonly}
spellCheck={true}
/>
</div>
<div className='open-button'>
<Button onClick={() => props.showCard(props.card.id)}>
<Button onClick={() => props.showCard(props.card.id || '')}>
<FormattedMessage
id='TableRow.open'
defaultMessage='Open'
@ -117,9 +120,9 @@ const TableRow = React.memo((props: Props) => {
{/* Columns, one per property */}
{board.cardProperties.
filter((template) => activeView.visiblePropertyIds.includes(template.id)).
map((template) => {
{board.fields.cardProperties.
filter((template: IPropertyTemplate) => activeView.fields.visiblePropertyIds.includes(template.id)).
map((template: IPropertyTemplate) => {
if (!columnRefs.get(template.id)) {
columnRefs.set(template.id, React.createRef())
}
@ -133,8 +136,9 @@ const TableRow = React.memo((props: Props) => {
<PropertyValueElement
readOnly={props.readonly}
card={card}
cardTree={props.cardTree}
boardTree={boardTree}
board={board}
contents={contents}
comments={comments}
propertyTemplate={template}
emptyDisplayValue=''
/>

View File

@ -2,7 +2,9 @@
// See LICENSE.txt for license information.
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {fireEvent, render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import '@testing-library/jest-dom'
import {IntlProvider} from 'react-intl'
@ -17,9 +19,6 @@ import userEvent from '@testing-library/user-event'
import {TestBlockFactory} from '../../test/testBlockFactory'
import {FetchMock} from '../../test/fetchMock'
import {MutableBoardTree} from '../../viewModel/boardTree'
import {CardTree, MutableCardTree} from '../../viewModel/cardTree'
import TableRows from './tableRows'
@ -42,30 +41,41 @@ describe('components/table/TableRows', () => {
const view = TestBlockFactory.createBoardView(board)
const view2 = TestBlockFactory.createBoardView(board)
view2.sortOptions = []
view2.fields.sortOptions = []
const card = TestBlockFactory.createCard(board)
const cardTemplate = TestBlockFactory.createCard(board)
cardTemplate.isTemplate = true
cardTemplate.fields.isTemplate = true
const mockStore = configureStore([])
const state = {
users: {},
comments: {
comments: {},
},
contents: {
contents: {},
},
cards: {
cards: {
[card.id]: card,
},
templates: {
[cardTemplate.id]: cardTemplate,
},
},
}
test('should match snapshot, fire events', async () => {
// Sync
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([board, view, view2, card, cardTemplate])))
const boardTree = await MutableBoardTree.sync(board.id, view.id, {})
expect(boardTree).toBeDefined()
expect(FetchMock.fn).toBeCalledTimes(1)
const callback = jest.fn()
const addCard = jest.fn()
const cardTrees:{ [key: string]: CardTree | undefined } = {}
cardTrees[card.id] = new MutableCardTree(card)
const store = mockStore(state)
const component = wrapProviders(
<ReduxProvider store={store}>
<TableRows
boardTree={boardTree!}
cardTrees={cardTrees}
board={board}
activeView={view}
columnRefs={new Map()}
cards={[card]}
selectedCardIds={[]}
@ -75,7 +85,8 @@ describe('components/table/TableRows', () => {
addCard={addCard}
onCardClicked={jest.fn()}
onDrop={jest.fn()}
/>,
/>
</ReduxProvider>,
)
const {container, getByTitle, getByText} = render(<DndProvider backend={HTML5Backend}>{component}</DndProvider>)

View File

@ -4,17 +4,16 @@ import React from 'react'
import {useDragLayer} from 'react-dnd'
import {Card} from '../../blocks/card'
import {BoardTree} from '../../viewModel/boardTree'
import {Board} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import './table.scss'
import {CardTree} from '../../viewModel/cardTree'
import TableRow from './tableRow'
type Props = {
boardTree: BoardTree
cardTrees: { [key: string]: CardTree | undefined }
board: Board
activeView: BoardView
columnRefs: Map<string, React.RefObject<HTMLDivElement>>
cards: readonly Card[]
selectedCardIds: string[]
@ -27,8 +26,7 @@ type Props = {
}
const TableRows = (props: Props) => {
const {boardTree, cards} = props
const {activeView} = boardTree
const {board, cards, activeView} = props
const {offset, resizingColumn} = useDragLayer((monitor) => {
if (monitor.getItemType() === 'horizontalGrip') {
@ -49,14 +47,14 @@ const TableRows = (props: Props) => {
const tableRow = (
<TableRow
key={card.id + card.updateAt}
boardTree={boardTree}
board={board}
activeView={activeView}
card={card}
cardTree={props.cardTrees[card.id]}
isSelected={props.selectedCardIds.includes(card.id)}
focusOnMount={props.cardIdToFocusOnRender === card.id}
onSaveWithEnter={() => {
if (cards.length > 0 && cards[cards.length - 1] === card) {
props.addCard(activeView.groupById ? card.properties[activeView.groupById!] as string : '')
props.addCard(activeView.fields.groupById ? card.fields.properties[activeView.fields.groupById!] as string : '')
}
}}
onClick={(e: React.MouseEvent<HTMLDivElement>) => {

View File

@ -3,11 +3,12 @@
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {FilterClause, FilterCondition} from '../../blocks/filterClause'
import {FilterGroup} from '../../blocks/filterGroup'
import {FilterClause, FilterCondition, createFilterClause} from '../../blocks/filterClause'
import {createFilterGroup, isAFilterGroupInstance} from '../../blocks/filterGroup'
import {Board, IPropertyTemplate} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import mutator from '../../mutator'
import {Utils} from '../../utils'
import {BoardTree} from '../../viewModel/boardTree'
import Button from '../../widgets/buttons/button'
import Modal from '../modal'
@ -17,53 +18,51 @@ import FilterEntry from './filterEntry'
import './filterComponent.scss'
type Props = {
boardTree: BoardTree
board: Board
activeView: BoardView
onClose: () => void
}
const FilterComponent = React.memo((props: Props): JSX.Element => {
const conditionClicked = (optionId: string, filter: FilterClause): void => {
const {boardTree} = props
const {activeView: view} = boardTree
const {activeView} = props
const filterIndex = view.filter.filters.indexOf(filter)
const filterIndex = activeView.fields.filter.filters.indexOf(filter)
Utils.assert(filterIndex >= 0, "Can't find filter")
const filterGroup = new FilterGroup(view.filter)
const filterGroup = createFilterGroup(activeView.fields.filter)
const newFilter = filterGroup.filters[filterIndex] as FilterClause
Utils.assert(newFilter, `No filter at index ${filterIndex}`)
if (newFilter.condition !== optionId) {
newFilter.condition = optionId as FilterCondition
mutator.changeViewFilter(view, filterGroup)
mutator.changeViewFilter(activeView, filterGroup)
}
}
const addFilterClicked = () => {
const {boardTree} = props
const {board, activeView: view} = boardTree
const {board, activeView} = props
const filters = view.filter?.filters.filter((o) => !FilterGroup.isAnInstanceOf(o)) as FilterClause[] || []
const filterGroup = new FilterGroup(view.filter)
const filter = new FilterClause()
const filters = activeView.fields.filter?.filters.filter((o) => !isAFilterGroupInstance(o)) as FilterClause[] || []
const filterGroup = createFilterGroup(activeView.fields.filter)
const filter = createFilterClause()
// Pick the first select property that isn't already filtered on
const selectProperty = board.cardProperties.
filter((o) => !filters.find((f) => f.propertyId === o.id)).
find((o) => o.type === 'select' || o.type === 'multiSelect')
const selectProperty = board.fields.cardProperties.
filter((o: IPropertyTemplate) => !filters.find((f) => f.propertyId === o.id)).
find((o: IPropertyTemplate) => o.type === 'select' || o.type === 'multiSelect')
if (selectProperty) {
filter.propertyId = selectProperty.id
}
filterGroup.filters.push(filter)
mutator.changeViewFilter(view, filterGroup)
mutator.changeViewFilter(activeView, filterGroup)
}
const {boardTree} = props
const {board, activeView} = boardTree
const {board, activeView} = props
// TODO: Handle FilterGroups (compound filter statements)
const filters: FilterClause[] = activeView.filter?.filters.filter((o) => !FilterGroup.isAnInstanceOf(o)) as FilterClause[] || []
const filters: FilterClause[] = activeView.fields.filter?.filters.filter((o) => !isAFilterGroupInstance(o)) as FilterClause[] || []
return (
<Modal

View File

@ -3,12 +3,12 @@
import React from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import {FilterClause} from '../../blocks/filterClause'
import {FilterGroup} from '../../blocks/filterGroup'
import {FilterClause, areEqual as areFilterClausesEqual} from '../../blocks/filterClause'
import {createFilterGroup, isAFilterGroupInstance} from '../../blocks/filterGroup'
import mutator from '../../mutator'
import {OctoUtils} from '../../octoUtils'
import {Utils} from '../../utils'
import {Board} from '../../blocks/board'
import {Board, IPropertyTemplate} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import Button from '../../widgets/buttons/button'
import Menu from '../../widgets/menu'
@ -29,7 +29,7 @@ const FilterEntry = React.memo((props: Props): JSX.Element => {
const {board, view, filter} = props
const intl = useIntl()
const template = board.cardProperties.find((o) => o.id === filter.propertyId)
const template = board.fields.cardProperties.find((o: IPropertyTemplate) => o.id === filter.propertyId)
const propertyName = template ? template.name : '(unknown)' // TODO: Handle error
const key = `${filter.propertyId}-${filter.condition}-${filter.values.join(',')}`
return (
@ -40,15 +40,15 @@ const FilterEntry = React.memo((props: Props): JSX.Element => {
<MenuWrapper>
<Button>{propertyName}</Button>
<Menu>
{board.cardProperties.filter((o) => o.type === 'select' || o.type === 'multiSelect').map((o) => (
{board.fields.cardProperties.filter((o: IPropertyTemplate) => o.type === 'select' || o.type === 'multiSelect').map((o: IPropertyTemplate) => (
<Menu.Text
key={o.id}
id={o.id}
name={o.name}
onClick={(optionId: string) => {
const filterIndex = view.filter.filters.indexOf(filter)
const filterIndex = view.fields.filter.filters.indexOf(filter)
Utils.assert(filterIndex >= 0, "Can't find filter")
const filterGroup = new FilterGroup(view.filter)
const filterGroup = createFilterGroup(view.fields.filter)
const newFilter = filterGroup.filters[filterIndex] as FilterClause
Utils.assert(newFilter, `No filter at index ${filterIndex}`)
if (newFilter.propertyId !== optionId) {
@ -94,8 +94,8 @@ const FilterEntry = React.memo((props: Props): JSX.Element => {
<div className='octo-spacer'/>
<Button
onClick={() => {
const filterGroup = new FilterGroup(view.filter)
filterGroup.filters = filterGroup.filters.filter((o) => FilterGroup.isAnInstanceOf(o) || !o.isEqual(filter))
const filterGroup = createFilterGroup(view.fields.filter)
filterGroup.filters = filterGroup.filters.filter((o) => isAFilterGroupInstance(o) || areFilterClausesEqual(o, filter))
mutator.changeViewFilter(view, filterGroup)
}}
>

View File

@ -4,7 +4,7 @@ import React from 'react'
import {IPropertyTemplate} from '../../blocks/board'
import {FilterClause} from '../../blocks/filterClause'
import {FilterGroup} from '../../blocks/filterGroup'
import {createFilterGroup} from '../../blocks/filterGroup'
import {BoardView} from '../../blocks/boardView'
import mutator from '../../mutator'
import {Utils} from '../../utils'
@ -47,10 +47,10 @@ const filterValue = (props: Props): JSX.Element|null => {
name={o.value}
isOn={filter.values.includes(o.id)}
onClick={(optionId) => {
const filterIndex = view.filter.filters.indexOf(filter)
const filterIndex = view.fields.filter.filters.indexOf(filter)
Utils.assert(filterIndex >= 0, "Can't find filter")
const filterGroup = new FilterGroup(view.filter)
const filterGroup = createFilterGroup(view.fields.filter)
const newFilter = filterGroup.filters[filterIndex] as FilterClause
Utils.assert(newFilter, `No filter at index ${filterIndex}`)
if (filter.values.includes(o.id)) {

View File

@ -4,16 +4,17 @@
import React from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import {BoardTree} from '../../viewModel/boardTree'
import {Card} from '../../blocks/card'
import ButtonWithMenu from '../../widgets/buttons/buttonWithMenu'
import CardIcon from '../../widgets/icons/card'
import AddIcon from '../../widgets/icons/add'
import Menu from '../../widgets/menu'
import {useAppSelector} from '../../store/hooks'
import {getCurrentBoardTemplates} from '../../store/cards'
import NewCardButtonTemplateItem from './newCardButtonTemplateItem'
type Props = {
boardTree: BoardTree
addCard: () => void
addCardFromTemplate: (cardTemplateId: string) => void
addCardTemplate: () => void
@ -21,7 +22,7 @@ type Props = {
}
const NewCardButton = React.memo((props: Props): JSX.Element => {
const {boardTree} = props
const cardTemplates: Card[] = useAppSelector(getCurrentBoardTemplates)
const intl = useIntl()
return (
@ -37,7 +38,7 @@ const NewCardButton = React.memo((props: Props): JSX.Element => {
)}
>
<Menu position='left'>
{boardTree.cardTemplates.length > 0 && <>
{cardTemplates.length > 0 && <>
<Menu.Label>
<b>
<FormattedMessage
@ -50,7 +51,7 @@ const NewCardButton = React.memo((props: Props): JSX.Element => {
<Menu.Separator/>
</>}
{boardTree.cardTemplates.map((cardTemplate) => (
{cardTemplates.map((cardTemplate) => (
<NewCardButtonTemplateItem
key={cardTemplate.id}
cardTemplate={cardTemplate}

View File

@ -28,7 +28,7 @@ const NewCardButtonTemplateItem = React.memo((props: Props) => {
key={cardTemplate.id}
id={cardTemplate.id}
name={displayName}
icon={<div className='Icon'>{cardTemplate.icon}</div>}
icon={<div className='Icon'>{cardTemplate.fields.icon}</div>}
onClick={() => {
props.addCardFromTemplate(cardTemplate.id)
}}

View File

@ -5,7 +5,9 @@ import {FormattedMessage} from 'react-intl'
import ViewMenu from '../../components/viewMenu'
import mutator from '../../mutator'
import {BoardTree} from '../../viewModel/boardTree'
import {Board, IPropertyTemplate} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import {Card} from '../../blocks/card'
import Button from '../../widgets/buttons/button'
import IconButton from '../../widgets/buttons/iconButton'
import DropdownIcon from '../../widgets/icons/dropdown'
@ -25,8 +27,11 @@ import FilterComponent from './filterComponent'
import './viewHeader.scss'
type Props = {
boardTree: BoardTree
setSearchText: (text?: string) => void
board: Board
activeView: BoardView
views: BoardView[]
cards: Card[]
groupByProperty?: IPropertyTemplate
addCard: () => void
addCardFromTemplate: (cardTemplateId: string) => void
addCardTemplate: () => void
@ -37,10 +42,9 @@ type Props = {
const ViewHeader = React.memo((props: Props) => {
const [showFilter, setShowFilter] = useState(false)
const {boardTree} = props
const {board, activeView} = boardTree
const {board, activeView, views, groupByProperty, cards} = props
const withGroupBy = activeView.viewType === 'board' || activeView.viewType === 'table'
const withGroupBy = activeView.fields.viewType === 'board' || activeView.fields.viewType === 'table'
const [viewTitle, setViewTitle] = useState(activeView.title)
@ -48,7 +52,7 @@ const ViewHeader = React.memo((props: Props) => {
setViewTitle(activeView.title)
}, [activeView.title])
const hasFilter = activeView.filter && activeView.filter.filters?.length > 0
const hasFilter = activeView.fields.filter && activeView.fields.filter.filters?.length > 0
return (
<div className='ViewHeader'>
@ -70,7 +74,8 @@ const ViewHeader = React.memo((props: Props) => {
<IconButton icon={<DropdownIcon/>}/>
<ViewMenu
board={board}
boardTree={boardTree}
activeView={activeView}
views={views}
readonly={props.readonly}
/>
</MenuWrapper>
@ -82,7 +87,7 @@ const ViewHeader = React.memo((props: Props) => {
{/* Card properties */}
<ViewHeaderPropertiesMenu
properties={board.cardProperties}
properties={board.fields.cardProperties}
activeView={activeView}
/>
@ -90,9 +95,9 @@ const ViewHeader = React.memo((props: Props) => {
{withGroupBy &&
<ViewHeaderGroupByMenu
properties={board.cardProperties}
properties={board.fields.cardProperties}
activeView={activeView}
groupByPropertyName={boardTree.groupByProperty?.name}
groupByPropertyName={groupByProperty?.name}
/>}
{/* Filter */}
@ -109,7 +114,8 @@ const ViewHeader = React.memo((props: Props) => {
</Button>
{showFilter &&
<FilterComponent
boardTree={boardTree}
board={board}
activeView={activeView}
onClose={() => setShowFilter(false)}
/>}
</ModalWrapper>
@ -117,32 +123,30 @@ const ViewHeader = React.memo((props: Props) => {
{/* Sort */}
<ViewHeaderSortMenu
properties={board.cardProperties}
properties={board.fields.cardProperties}
activeView={activeView}
orderedCards={boardTree.orderedCards()}
orderedCards={cards}
/>
</>
}
{/* Search */}
<ViewHeaderSearch
boardTree={boardTree}
setSearchText={props.setSearchText}
/>
<ViewHeaderSearch/>
{/* Options menu */}
{!props.readonly &&
<>
<ViewHeaderActionsMenu
boardTree={boardTree}
board={board}
activeView={activeView}
cards={cards}
/>
{/* New card button */}
<NewCardButton
boardTree={boardTree}
addCard={props.addCard}
addCardFromTemplate={props.addCardFromTemplate}
addCardTemplate={props.addCardTemplate}

View File

@ -6,12 +6,14 @@ import {useIntl, IntlShape} from 'react-intl'
import {CsvExporter} from '../../csvExporter'
import {Archiver} from '../../archiver'
import {IUser} from '../../user'
import {BoardTree} from '../../viewModel/boardTree'
import {Board} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import {Card} from '../../blocks/card'
import IconButton from '../../widgets/buttons/iconButton'
import OptionsIcon from '../../widgets/icons/options'
import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper'
import {getCurrentUser} from '../../store/currentUser'
import {getMe} from '../../store/users'
import {useAppSelector} from '../../store/hooks'
import ModalWrapper from '../modalWrapper'
@ -19,29 +21,32 @@ import ShareBoardComponent from '../shareBoardComponent'
import {sendFlashMessage} from '../flashMessages'
type Props = {
boardTree: BoardTree
board: Board
activeView: BoardView
cards: Card[]
}
// async function testAddCards(boardTree: BoardTree, count: number) {
// const {board, activeView} = boardTree
// const startCount = boardTree.cards.length
// import {mutator} from '../../mutator'
// import {CardFilter} from '../../cardFilter'
// import {BlockIcons} from '../../blockIcons'
// async function testAddCards(board: Board, activeView: BoardView, startCount: number, count: number) {
// let optionIndex = 0
// mutator.performAsUndoGroup(async () => {
// for (let i = 0; i < count; i++) {
// const card = new MutableCard()
// card.parentId = boardTree.board.id
// card.rootId = boardTree.board.rootId
// card.properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
// const card = new Card()
// card.parentId = board.id
// card.rootId = board.rootId
// card.fields.properties = CardFilter.propertiesThatMeetFilterGroup(activeView.fields.filter, board.fields.cardProperties)
// card.title = `Test Card ${startCount + i + 1}`
// card.icon = BlockIcons.shared.randomIcon()
// card.fields.icon = BlockIcons.shared.randomIcon()
// if (boardTree.groupByProperty && boardTree.groupByProperty.options.length > 0) {
// const groupByProperty = board.fields.cardProperties.find((o) => o.id === activeView.fields.groupById)
// if (groupByProperty && groupByProperty.options.length > 0) {
// // Cycle through options
// const option = boardTree.groupByProperty.options[optionIndex]
// optionIndex = (optionIndex + 1) % boardTree.groupByProperty.options.length
// card.properties[boardTree.groupByProperty.id] = option.id
// const option = groupByProperty.options[optionIndex]
// optionIndex = (optionIndex + 1) % groupByProperty.options.length
// card.fields.properties[groupByProperty.id] = option.id
// }
// mutator.insertBlock(card, 'test add card')
// }
@ -56,7 +61,7 @@ type Props = {
// // Cycle through options
// const option = boardTree.groupByProperty.options[optionIndex]
// optionIndex = (optionIndex + 1) % boardTree.groupByProperty.options.length
// const newCard = new MutableCard(card)
// const newCard = new Card(card)
// if (newCard.properties[boardTree.groupByProperty.id] !== option.id) {
// newCard.properties[boardTree.groupByProperty.id] = option.id
// mutator.updateBlock(newCard, card, 'test distribute cards')
@ -74,9 +79,9 @@ type Props = {
// })
// }
function onExportCsvTrigger(boardTree: BoardTree, intl: IntlShape) {
function onExportCsvTrigger(board: Board, activeView: BoardView, cards: Card[], intl: IntlShape) {
try {
CsvExporter.exportTableCsv(boardTree, intl)
CsvExporter.exportTableCsv(board, activeView, cards, intl)
const exportCompleteMessage = intl.formatMessage({
id: 'ViewHeader.export-complete',
defaultMessage: 'Export complete!',
@ -94,8 +99,8 @@ function onExportCsvTrigger(boardTree: BoardTree, intl: IntlShape) {
const ViewHeaderActionsMenu = React.memo((props: Props) => {
const [showShareDialog, setShowShareDialog] = useState(false)
const {boardTree} = props
const user = useAppSelector<IUser|null>(getCurrentUser)
const {board, activeView, cards} = props
const user = useAppSelector<IUser|null>(getMe)
const intl = useIntl()
return (
@ -106,12 +111,12 @@ const ViewHeaderActionsMenu = React.memo((props: Props) => {
<Menu.Text
id='exportCsv'
name={intl.formatMessage({id: 'ViewHeader.export-csv', defaultMessage: 'Export to CSV'})}
onClick={() => onExportCsvTrigger(boardTree, intl)}
onClick={() => onExportCsvTrigger(board, activeView, cards, intl)}
/>
<Menu.Text
id='exportBoardArchive'
name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export board archive'})}
onClick={() => Archiver.exportBoardArchive(boardTree)}
onClick={() => Archiver.exportBoardArchive(board)}
/>
{user && user.id !== 'single-user' &&
<Menu.Text
@ -122,18 +127,17 @@ const ViewHeaderActionsMenu = React.memo((props: Props) => {
}
{/*
<Menu.Separator/>
<Menu.Text
id='testAdd100Cards'
name={intl.formatMessage({id: 'ViewHeader.test-add-100-cards', defaultMessage: 'TEST: Add 100 cards'})}
onClick={() => testAddCards(100)}
onClick={() => testAddCards(board, activeView, cards.length, 100)}
/>
<Menu.Text
id='testAdd1000Cards'
name={intl.formatMessage({id: 'ViewHeader.test-add-1000-cards', defaultMessage: 'TEST: Add 1,000 cards'})}
onClick={() => testAddCards(1000)}
onClick={() => testAddCards(board, activeView, cards.length, 1000)}
/>
<Menu.Text
id='testDistributeCards'
@ -145,13 +149,12 @@ const ViewHeaderActionsMenu = React.memo((props: Props) => {
name={intl.formatMessage({id: 'ViewHeader.test-randomize-icons', defaultMessage: 'TEST: Randomize icons'})}
onClick={() => testRandomizeIcons()}
/>
*/}
</Menu>
</MenuWrapper>
{showShareDialog &&
<ShareBoardComponent
boardId={boardTree.board.id}
boardId={board.id || ''}
onClose={() => setShowShareDialog(false)}
/>
}

View File

@ -40,15 +40,15 @@ const ViewHeaderGroupByMenu = React.memo((props: Props) => {
/>
</Button>
<Menu>
{activeView.viewType === 'table' && activeView.groupById &&
{activeView.fields.viewType === 'table' && activeView.fields.groupById &&
<>
<Menu.Text
key={'ungroup'}
id={''}
name={intl.formatMessage({id: 'GroupBy.ungroup', defaultMessage: 'Ungroup'})}
rightIcon={activeView.groupById === '' ? <CheckIcon/> : undefined}
rightIcon={activeView.fields.groupById === '' ? <CheckIcon/> : undefined}
onClick={(id) => {
if (activeView.groupById === id) {
if (activeView.fields.groupById === id) {
return
}
mutator.changeViewGroupById(activeView, id)
@ -56,14 +56,14 @@ const ViewHeaderGroupByMenu = React.memo((props: Props) => {
/>
<Menu.Separator/>
</>}
{properties.filter((o: IPropertyTemplate) => o.type === 'select').map((option: IPropertyTemplate) => (
{properties?.filter((o: IPropertyTemplate) => o.type === 'select').map((option: IPropertyTemplate) => (
<Menu.Text
key={option.id}
id={option.id}
name={option.name}
rightIcon={activeView.groupById === option.id ? <CheckIcon/> : undefined}
rightIcon={activeView.fields.groupById === option.id ? <CheckIcon/> : undefined}
onClick={(id) => {
if (activeView.groupById === id) {
if (activeView.fields.groupById === id) {
return
}

View File

@ -27,34 +27,34 @@ const ViewHeaderPropertiesMenu = React.memo((props: Props) => {
/>
</Button>
<Menu>
{activeView.viewType === 'gallery' &&
{activeView.fields.viewType === 'gallery' &&
<Menu.Switch
key={Constants.titleColumnId}
id={Constants.titleColumnId}
name={intl.formatMessage({id: 'default-properties.title', defaultMessage: 'Title'})}
isOn={activeView.visiblePropertyIds.includes(Constants.titleColumnId)}
isOn={activeView.fields.visiblePropertyIds.includes(Constants.titleColumnId)}
onClick={(propertyId: string) => {
let newVisiblePropertyIds = []
if (activeView.visiblePropertyIds.includes(propertyId)) {
newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o: string) => o !== propertyId)
if (activeView.fields.visiblePropertyIds.includes(propertyId)) {
newVisiblePropertyIds = activeView.fields.visiblePropertyIds.filter((o: string) => o !== propertyId)
} else {
newVisiblePropertyIds = [...activeView.visiblePropertyIds, propertyId]
newVisiblePropertyIds = [...activeView.fields.visiblePropertyIds, propertyId]
}
mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
}}
/>}
{properties.map((option: IPropertyTemplate) => (
{properties?.map((option: IPropertyTemplate) => (
<Menu.Switch
key={option.id}
id={option.id}
name={option.name}
isOn={activeView.visiblePropertyIds.includes(option.id)}
isOn={activeView.fields.visiblePropertyIds.includes(option.id)}
onClick={(propertyId: string) => {
let newVisiblePropertyIds = []
if (activeView.visiblePropertyIds.includes(propertyId)) {
newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o: string) => o !== propertyId)
if (activeView.fields.visiblePropertyIds.includes(propertyId)) {
newVisiblePropertyIds = activeView.fields.visiblePropertyIds.filter((o: string) => o !== propertyId)
} else {
newVisiblePropertyIds = [...activeView.visiblePropertyIds, propertyId]
newVisiblePropertyIds = [...activeView.fields.visiblePropertyIds, propertyId]
}
mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
}}

View File

@ -4,22 +4,20 @@ import React, {useState, useRef, useEffect} from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import {useHotkeys} from 'react-hotkeys-hook'
import {BoardTree} from '../../viewModel/boardTree'
import Button from '../../widgets/buttons/button'
import Editable from '../../widgets/editable'
type Props = {
boardTree: BoardTree
setSearchText: (text?: string) => void
}
import {useAppSelector, useAppDispatch} from '../../store/hooks'
import {getSearchText, setSearchText} from '../../store/searchText'
const ViewHeaderSearch = (props: Props) => {
const {boardTree, setSearchText} = props
const ViewHeaderSearch = () => {
const searchText = useAppSelector<string>(getSearchText)
const dispatch = useAppDispatch()
const intl = useIntl()
const searchFieldRef = useRef<{focus(selectAll?: boolean): void}>(null)
const [isSearching, setIsSearching] = useState(Boolean(boardTree.getSearchText()))
const [searchValue, setSearchValue] = useState(boardTree.getSearchText())
const [isSearching, setIsSearching] = useState(Boolean(searchText))
const [searchValue, setSearchValue] = useState(searchText)
useEffect(() => {
searchFieldRef.current?.focus()
@ -40,13 +38,13 @@ const ViewHeaderSearch = (props: Props) => {
onCancel={() => {
setSearchValue('')
setIsSearching(false)
setSearchText('')
dispatch(setSearchText(''))
}}
onSave={() => {
if (searchValue === '') {
setIsSearching(false)
}
setSearchText(searchValue)
dispatch(setSearchText(searchValue))
}}
/>
)

View File

@ -1,10 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import React, {useCallback} from 'react'
import {FormattedMessage} from 'react-intl'
import {IPropertyTemplate} from '../../blocks/board'
import {BoardView, MutableBoardView, ISortOption} from '../../blocks/boardView'
import {BoardView, ISortOption} from '../../blocks/boardView'
import {Constants} from '../../constants'
import {Card} from '../../blocks/card'
import mutator from '../../mutator'
@ -21,9 +21,37 @@ type Props = {
}
const ViewHeaderSortMenu = React.memo((props: Props) => {
const {properties, activeView, orderedCards} = props
const hasSort = activeView.sortOptions.length > 0
const sortDisplayOptions = properties.map((o) => ({id: o.id, name: o.name}))
sortDisplayOptions.unshift({id: Constants.titleColumnId, name: 'Name'})
const hasSort = activeView.fields.sortOptions?.length > 0
const sortDisplayOptions = properties?.map((o) => ({id: o.id, name: o.name}))
sortDisplayOptions?.unshift({id: Constants.titleColumnId, name: 'Name'})
const sortChanged = useCallback((propertyId: string) => {
let newSortOptions: ISortOption[] = []
if (activeView.fields.sortOptions && activeView.fields.sortOptions[0] && activeView.fields.sortOptions[0].propertyId === propertyId) {
// Already sorting by name, so reverse it
newSortOptions = [
{propertyId, reversed: !activeView.fields.sortOptions[0].reversed},
]
} else {
newSortOptions = [
{propertyId, reversed: false},
]
}
mutator.changeViewSortOptions(activeView, newSortOptions)
}, [activeView])
const onManualSort = useCallback(() => {
// 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 = {...activeView, fields: {...activeView.fields}}
newView.fields.cardOrder = orderedCards.map((o) => o.id || '') || []
newView.fields.sortOptions = []
mutator.updateBlock(newView, activeView, 'reorder')
}, [activeView, orderedCards])
const onRevertSort = useCallback(() => {
mutator.changeViewSortOptions(activeView, [])
}, [activeView])
return (
<MenuWrapper>
@ -34,37 +62,28 @@ const ViewHeaderSortMenu = React.memo((props: Props) => {
/>
</Button>
<Menu>
{(activeView.sortOptions.length > 0) &&
{(activeView.fields.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 = orderedCards.map((o) => o.id)
newView.sortOptions = []
mutator.updateBlock(newView, activeView, 'reorder')
}}
onClick={onManualSort}
/>
<Menu.Text
id='revert'
name='Revert'
onClick={() => {
mutator.changeViewSortOptions(activeView, [])
}}
onClick={onRevertSort}
/>
<Menu.Separator/>
</>
}
{sortDisplayOptions.map((option) => {
{sortDisplayOptions?.map((option) => {
let rightIcon: JSX.Element | undefined
if (activeView.sortOptions.length > 0) {
const sortOption = activeView.sortOptions[0]
if (activeView.fields.sortOptions?.length > 0) {
const sortOption = activeView.fields.sortOptions[0]
if (sortOption.propertyId === option.id) {
rightIcon = sortOption.reversed ? <SortDownIcon/> : <SortUpIcon/>
}
@ -75,20 +94,7 @@ const ViewHeaderSortMenu = React.memo((props: Props) => {
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)
}}
onClick={sortChanged}
/>
)
})}

View File

@ -4,12 +4,11 @@ import React, {useCallback} from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {generatePath, useHistory, useRouteMatch} from 'react-router-dom'
import {Board} from '../blocks/board'
import {BoardView, IViewType, MutableBoardView} from '../blocks/boardView'
import {Board, IPropertyTemplate} from '../blocks/board'
import {IViewType, BoardView, createBoardView} 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'
@ -19,8 +18,9 @@ import GalleryIcon from '../widgets/icons/gallery'
import Menu from '../widgets/menu'
type Props = {
boardTree: BoardTree
board: Board,
activeView: BoardView,
views: BoardView[],
intl: IntlShape
readonly: boolean
}
@ -35,11 +35,12 @@ const ViewMenu = React.memo((props: Props) => {
}, [match, history])
const handleDuplicateView = useCallback(() => {
const {boardTree} = props
const {activeView} = props
Utils.log('duplicateView')
const currentViewId = boardTree.activeView.id
const newView = boardTree.activeView.duplicate()
newView.title = `${boardTree.activeView.title} copy`
const currentViewId = activeView.id
const newView = createBoardView(activeView)
newView.title = `${activeView.title} copy`
newView.id = Utils.createGuid()
mutator.insertBlock(
newView,
'duplicate view',
@ -53,39 +54,39 @@ const ViewMenu = React.memo((props: Props) => {
showView(currentViewId)
},
)
}, [props.boardTree, showView])
}, [props.activeView, showView])
const handleDeleteView = useCallback(() => {
const {boardTree} = props
const {activeView, views} = props
Utils.log('deleteView')
const view = boardTree.activeView
const nextView = boardTree.views.find((o) => o !== view)
const view = activeView
const nextView = views.find((o) => o !== view)
mutator.deleteBlock(view, 'delete view')
if (nextView) {
showView(nextView.id)
}
}, [props.boardTree, showView])
}, [props.views, props.activeView, showView])
const handleViewClick = useCallback((id: string) => {
const {boardTree} = props
const {views} = props
Utils.log('view ' + id)
const view = boardTree.views.find((o) => o.id === id)
const view = views.find((o) => o.id === id)
Utils.assert(view, `view not found: ${id}`)
if (view) {
showView(view.id)
}
}, [props.boardTree, showView])
}, [props.views, showView])
const handleAddViewBoard = useCallback(() => {
const {board, boardTree, intl} = props
const {board, activeView, intl} = props
Utils.log('addview-board')
const view = new MutableBoardView()
const view = createBoardView()
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board view'})
view.viewType = 'board'
view.fields.viewType = 'board'
view.parentId = board.id
view.rootId = board.rootId
const oldViewId = boardTree.activeView.id
const oldViewId = activeView.id
mutator.insertBlock(
view,
@ -99,22 +100,22 @@ const ViewMenu = React.memo((props: Props) => {
async () => {
showView(oldViewId)
})
}, [props.boardTree, props.board, props.intl, showView])
}, [props.activeView, props.board, props.intl, showView])
const handleAddViewTable = useCallback(() => {
const {board, boardTree, intl} = props
const {board, activeView, intl} = props
Utils.log('addview-table')
const view = new MutableBoardView()
const view = createBoardView()
view.title = intl.formatMessage({id: 'View.NewTableTitle', defaultMessage: 'Table view'})
view.viewType = 'table'
view.fields.viewType = 'table'
view.parentId = board.id
view.rootId = board.rootId
view.visiblePropertyIds = board.cardProperties.map((o) => o.id)
view.columnWidths = {}
view.columnWidths[Constants.titleColumnId] = Constants.defaultTitleColumnWidth
view.fields.visiblePropertyIds = board.fields.cardProperties.map((o: IPropertyTemplate) => o.id)
view.fields.columnWidths = {}
view.fields.columnWidths[Constants.titleColumnId] = Constants.defaultTitleColumnWidth
const oldViewId = boardTree.activeView.id
const oldViewId = activeView.id
mutator.insertBlock(
view,
@ -129,20 +130,20 @@ const ViewMenu = React.memo((props: Props) => {
async () => {
showView(oldViewId)
})
}, [props.boardTree, props.board, props.intl, showView])
}, [props.activeView, props.board, props.intl, showView])
const handleAddViewGallery = useCallback(() => {
const {board, boardTree, intl} = props
const {board, activeView, intl} = props
Utils.log('addview-gallery')
const view = new MutableBoardView()
const view = createBoardView()
view.title = intl.formatMessage({id: 'View.NewGalleryTitle', defaultMessage: 'Gallery view'})
view.viewType = 'gallery'
view.fields.viewType = 'gallery'
view.parentId = board.id
view.rootId = board.rootId
view.visiblePropertyIds = [Constants.titleColumnId]
view.fields.visiblePropertyIds = [Constants.titleColumnId]
const oldViewId = boardTree.activeView.id
const oldViewId = activeView.id
mutator.insertBlock(
view,
@ -157,9 +158,9 @@ const ViewMenu = React.memo((props: Props) => {
async () => {
showView(oldViewId)
})
}, [props.board, props.boardTree, props.intl, showView])
}, [props.board, props.activeView, props.intl, showView])
const {boardTree, intl} = props
const {views, intl} = props
const duplicateViewText = intl.formatMessage({
id: 'View.DuplicateView',
@ -193,12 +194,12 @@ const ViewMenu = React.memo((props: Props) => {
return (
<Menu>
{boardTree.views.map((view: BoardView) => (
{views.map((view: BoardView) => (
<Menu.Text
key={view.id}
id={view.id}
name={view.title}
icon={iconForViewType(view.viewType)}
icon={iconForViewType(view.fields.viewType)}
onClick={handleViewClick}
/>))}
<Menu.Separator/>
@ -210,7 +211,7 @@ const ViewMenu = React.memo((props: Props) => {
onClick={handleDuplicateView}
/>
}
{!props.readonly && boardTree.views.length > 1 &&
{!props.readonly && views.length > 1 &&
<Menu.Text
id='__deleteView'
name={deleteViewText}

View File

@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react'
import React, {useState, useCallback} from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import {BlockIcons} from '../blockIcons'
@ -22,15 +22,19 @@ type Props = {
}
const ViewTitle = React.memo((props: Props) => {
const [title, setTitle] = useState(props.board.title)
const {board} = props
const [title, setTitle] = useState(board.title)
const onEditTitleSave = useCallback(() => mutator.changeTitle(board, title), [board, title])
const onEditTitleCancel = useCallback(() => setTitle(board.title), [board])
const onDescriptionBlur = useCallback((text) => mutator.changeDescription(board, text), [board])
const intl = useIntl()
return (
<div className='ViewTitle'>
<div className='add-buttons add-visible'>
{!props.readonly && !board.icon &&
{!props.readonly && !board.fields.icon &&
<Button
onClick={() => {
const newIcon = BlockIcons.shared.randomIcon()
@ -44,7 +48,7 @@ const ViewTitle = React.memo((props: Props) => {
/>
</Button>
}
{!props.readonly && board.showDescription &&
{!props.readonly && board.fields.showDescription &&
<Button
onClick={() => {
mutator.showDescription(board, false)
@ -57,7 +61,7 @@ const ViewTitle = React.memo((props: Props) => {
/>
</Button>
}
{!props.readonly && !board.showDescription &&
{!props.readonly && !board.fields.showDescription &&
<Button
onClick={() => {
mutator.showDescription(board, true)
@ -80,21 +84,19 @@ const ViewTitle = React.memo((props: Props) => {
placeholderText={intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'})}
onChange={(newTitle) => setTitle(newTitle)}
saveOnEsc={true}
onSave={() => mutator.changeTitle(board, title)}
onCancel={() => setTitle(props.board.title)}
onSave={onEditTitleSave}
onCancel={onEditTitleCancel}
readonly={props.readonly}
spellCheck={true}
/>
</div>
{board.showDescription &&
{board.fields.showDescription &&
<div className='description'>
<MarkdownEditor
text={board.description}
text={board.fields.description}
placeholderText='Add a description...'
onBlur={(text) => {
mutator.changeDescription(board, text)
}}
onBlur={onDescriptionBlur}
readonly={props.readonly}
/>
</div>

View File

@ -1,12 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {useRouteMatch} from 'react-router-dom'
import {FormattedMessage} from 'react-intl'
import {IWorkspace} from '../blocks/workspace'
import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree'
import {WorkspaceTree} from '../viewModel/workspaceTree'
import {getCurrentBoard} from '../store/boards'
import {getCurrentViewCardsSortedFilteredAndGrouped} from '../store/cards'
import {getView, getCurrentBoardViews, getCurrentViewGroupBy, getCurrentView} from '../store/views'
import {useAppSelector} from '../store/hooks'
import CenterPanel from './centerPanel'
import EmptyCenterPanel from './emptyCenterPanel'
@ -14,56 +15,60 @@ import Sidebar from './sidebar/sidebar'
import './workspace.scss'
type Props = {
workspace?: IWorkspace
workspaceTree: WorkspaceTree
boardTree?: BoardTree
setSearchText: (text?: string) => void
readonly: boolean
}
function centerContent(props: Props) {
const {workspace, boardTree, setSearchText} = props
const {activeView} = boardTree || {}
function CenterContent(props: Props) {
const match = useRouteMatch<{boardId: string, viewId: string}>()
const board = useAppSelector(getCurrentBoard)
const cards = useAppSelector(getCurrentViewCardsSortedFilteredAndGrouped)
const activeView = useAppSelector(getView(match.params.viewId))
const views = useAppSelector(getCurrentBoardViews)
const groupByProperty = useAppSelector(getCurrentViewGroupBy)
if (boardTree && activeView) {
if (board && activeView) {
let property = groupByProperty
if ((!property || property.type !== 'select') && activeView.fields.viewType === 'board') {
property = board?.fields.cardProperties.find((o) => o.type === 'select')
}
return (
<CenterPanel
boardTree={boardTree}
setSearchText={setSearchText}
readonly={props.readonly}
board={board}
cards={cards}
activeView={activeView}
groupByProperty={property}
views={views}
/>
)
}
return (
<EmptyCenterPanel workspace={workspace}/>
<EmptyCenterPanel/>
)
}
const Workspace = React.memo((props: Props) => {
const {workspace, boardTree, workspaceTree} = props
Utils.assert(workspaceTree || !props.readonly)
const board = useAppSelector(getCurrentBoard)
const view = useAppSelector(getCurrentView)
return (
<div className='Workspace'>
{!props.readonly &&
<Sidebar
workspace={workspace}
workspaceTree={workspaceTree}
activeBoardId={boardTree?.board.id}
activeViewId={boardTree?.activeView.id}
activeBoardId={board?.id}
activeViewId={view?.id}
/>
}
<div className='mainFrame'>
{(boardTree?.board.isTemplate) &&
{(board?.fields.isTemplate) &&
<div className='banner'>
<FormattedMessage
id='Workspace.editing-board-template'
defaultMessage="You're editing a board template"
/>
</div>}
{centerContent(props)}
<CenterContent readonly={props.readonly}/>
</div>
</div>
)

View File

@ -3,20 +3,20 @@
import {IntlShape} from 'react-intl'
import {BoardView} from './blocks/boardView'
import {BoardTree} from './viewModel/boardTree'
import {Board, IPropertyTemplate} from './blocks/board'
import {Card} from './blocks/card'
import {OctoUtils} from './octoUtils'
import {Utils} from './utils'
class CsvExporter {
static exportTableCsv(boardTree: BoardTree, intl: IntlShape, view?: BoardView): void {
const {activeView} = boardTree
static exportTableCsv(board: Board, activeView: BoardView, cards: Card[], intl: IntlShape, view?: BoardView): void {
const viewToExport = view ?? activeView
if (!viewToExport) {
return
}
const rows = CsvExporter.generateTableArray(boardTree, viewToExport, intl)
const rows = CsvExporter.generateTableArray(board, cards, viewToExport, intl)
let csvContent = 'data:text/csv;charset=utf-8,'
@ -47,16 +47,14 @@ class CsvExporter {
return text.replace(/"/g, '""')
}
private static generateTableArray(boardTree: BoardTree, viewToExport: BoardView, intl: IntlShape): string[][] {
const {board, cards} = boardTree
private static generateTableArray(board: Board, cards: Card[], viewToExport: BoardView, intl: IntlShape): string[][] {
const rows: string[][] = []
const visibleProperties = board.cardProperties.filter((template) => viewToExport.visiblePropertyIds.includes(template.id))
const visibleProperties = board.fields.cardProperties.filter((template: IPropertyTemplate) => viewToExport.fields.visiblePropertyIds.includes(template.id))
{
// Header row
const row: string[] = ['Title']
visibleProperties.forEach((template) => {
visibleProperties.forEach((template: IPropertyTemplate) => {
row.push(template.name)
})
rows.push(row)
@ -65,8 +63,8 @@ class CsvExporter {
cards.forEach((card) => {
const row: string[] = []
row.push(`"${this.encodeText(card.title)}"`)
visibleProperties.forEach((template) => {
const propertyValue = card.properties[template.id]
visibleProperties.forEach((template: IPropertyTemplate) => {
const propertyValue = card.fields.properties[template.id]
const displayValue = (OctoUtils.propertyDisplayValue(card, propertyValue, template, intl) || '') as string
if (template.type === 'number') {
const numericValue = propertyValue ? Number(propertyValue).toString() : ''

View File

@ -2,12 +2,12 @@
// See LICENSE.txt for license information.
import {useEffect} from 'react'
import {IBlock} from '../blocks/block'
import {Block} from '../blocks/block'
import wsClient, {WSClient} from '../wsclient'
export default function useCardListener(onChange: (blocks: IBlock[]) => void, onReconnect: () => void): void {
export default function useCardListener(onChange: (blocks: Block[]) => void, onReconnect: () => void): void {
useEffect(() => {
const onChangeHandler = (_: WSClient, blocks: IBlock[]) => onChange(blocks)
const onChangeHandler = (_: WSClient, blocks: Block[]) => onChange(blocks)
wsClient.addOnChange(onChangeHandler)
wsClient.addOnReconnect(onReconnect)
return () => {

View File

@ -1,16 +1,15 @@
// 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 {Block, createBlock} from './blocks/block'
import {Board, IPropertyOption, IPropertyTemplate, PropertyType, createBoard} from './blocks/board'
import {BoardView, ISortOption, createBoardView} from './blocks/boardView'
import {Card, createCard} from './blocks/card'
import {FilterGroup} from './blocks/filterGroup'
import octoClient, {OctoClient} from './octoClient'
import {OctoUtils} from './octoUtils'
import undoManager from './undomanager'
import {Utils} from './utils'
import {BoardTree} from './viewModel/boardTree'
//
// The Mutator is used to make all changes to server state
@ -48,7 +47,7 @@ class Mutator {
}
}
async updateBlock(newBlock: IBlock, oldBlock: IBlock, description: string): Promise<void> {
async updateBlock(newBlock: Block, oldBlock: Block, description: string): Promise<void> {
await undoManager.perform(
async () => {
await octoClient.updateBlock(newBlock)
@ -61,7 +60,7 @@ class Mutator {
)
}
private async updateBlocks(newBlocks: IBlock[], oldBlocks: IBlock[], description: string): Promise<void> {
private async updateBlocks(newBlocks: Block[], oldBlocks: Block[], description: string): Promise<void> {
await undoManager.perform(
async () => {
await octoClient.updateBlocks(newBlocks)
@ -74,7 +73,7 @@ class Mutator {
)
}
async insertBlock(block: IBlock, description = 'add', afterRedo?: () => Promise<void>, beforeUndo?: () => Promise<void>) {
async insertBlock(block: Block, description = 'add', afterRedo?: () => Promise<void>, beforeUndo?: () => Promise<void>) {
await undoManager.perform(
async () => {
await octoClient.insertBlock(block)
@ -89,7 +88,7 @@ class Mutator {
)
}
async insertBlocks(blocks: IBlock[], description = 'add', afterRedo?: () => Promise<void>, beforeUndo?: () => Promise<void>) {
async insertBlocks(blocks: Block[], description = 'add', afterRedo?: () => Promise<void>, beforeUndo?: () => Promise<void>) {
await undoManager.perform(
async () => {
await octoClient.insertBlocks(blocks)
@ -108,7 +107,7 @@ class Mutator {
)
}
async deleteBlock(block: IBlock, description?: string, beforeRedo?: () => Promise<void>, afterUndo?: () => Promise<void>) {
async deleteBlock(block: Block, description?: string, beforeRedo?: () => Promise<void>, afterUndo?: () => Promise<void>) {
const actualDescription = description || `delete ${block.type}`
await undoManager.perform(
@ -125,24 +124,25 @@ class Mutator {
)
}
async changeTitle(block: IBlock, title: string, description = 'change title') {
const newBlock = new MutableBlock(block)
async changeTitle(block: Block, title: string, description = 'change title') {
const newBlock = createBlock(block)
newBlock.title = title
await this.updateBlock(newBlock, block, description)
}
async changeIcon(block: Card | Board, icon: string, description = 'change icon') {
let newBlock: IBlock
let newBlock: Block
switch (block.type) {
case 'card': {
const card = new MutableCard(block)
card.icon = icon
const card = createCard(block)
card.fields.icon = icon
newBlock = card
break
}
case 'board': {
const board = new MutableBoard(block)
board.icon = icon
const board = createBoard(block)
board.fields.icon = icon
newBlock = board
break
}
@ -155,15 +155,15 @@ class Mutator {
await this.updateBlock(newBlock, block, description)
}
async changeDescription(block: IBlock, boardDescription: string, description = 'change description') {
const newBoard = new MutableBoard(block)
newBoard.description = boardDescription
async changeDescription(block: Block, boardDescription: string, description = 'change description') {
const newBoard = createBoard(block)
newBoard.fields.description = boardDescription
await this.updateBlock(newBoard, block, description)
}
async showDescription(board: Board, showDescription = true, description?: string) {
const newBoard = new MutableBoard(board)
newBoard.showDescription = showDescription
const newBoard = createBoard(board)
newBoard.fields.showDescription = showDescription
let actionDescription = description
if (!actionDescription) {
actionDescription = showDescription ? 'show description' : 'hide description'
@ -172,15 +172,14 @@ class Mutator {
}
async changeCardContentOrder(card: Card, contentOrder: Array<string | string[]>, description = 'reorder'): Promise<void> {
const newCard = new MutableCard(card)
newCard.contentOrder = contentOrder
const newCard = createCard(card)
newCard.fields.contentOrder = contentOrder
await this.updateBlock(newCard, card, description)
}
// Property Templates
async insertPropertyTemplate(boardTree: BoardTree, index = -1, template?: IPropertyTemplate) {
const {board, activeView} = boardTree
async insertPropertyTemplate(board: Board, activeView: BoardView, index = -1, template?: IPropertyTemplate) {
if (!activeView) {
Utils.assertFailure('insertPropertyTemplate: no activeView')
return
@ -193,20 +192,20 @@ class Mutator {
options: [],
}
const oldBlocks: IBlock[] = [board]
const oldBlocks: Block[] = [board]
const newBoard = new MutableBoard(board)
const startIndex = (index >= 0) ? index : board.cardProperties.length
newBoard.cardProperties.splice(startIndex, 0, newTemplate)
const changedBlocks: IBlock[] = [newBoard]
const newBoard = createBoard(board)
const startIndex = (index >= 0) ? index : board.fields.cardProperties.length
newBoard.fields.cardProperties.splice(startIndex, 0, newTemplate)
const changedBlocks: Block[] = [newBoard]
let description = 'add property'
if (activeView.viewType === 'table') {
if (activeView.fields.viewType === 'table') {
oldBlocks.push(activeView)
const newActiveView = new MutableBoardView(activeView)
newActiveView.visiblePropertyIds.push(newTemplate.id)
const newActiveView = createBoardView(activeView)
newActiveView.fields.visiblePropertyIds.push(newTemplate.id)
changedBlocks.push(newActiveView)
description = 'add column'
@ -215,37 +214,36 @@ class Mutator {
await this.updateBlocks(changedBlocks, oldBlocks, description)
}
async duplicatePropertyTemplate(boardTree: BoardTree, propertyId: string) {
const {board, activeView} = boardTree
async duplicatePropertyTemplate(board: Board, activeView: BoardView, propertyId: string) {
if (!activeView) {
Utils.assertFailure('duplicatePropertyTemplate: no activeView')
return
}
const oldBlocks: IBlock[] = [board]
const oldBlocks: Block[] = [board]
const newBoard = new MutableBoard(board)
const changedBlocks: IBlock[] = [newBoard]
const index = newBoard.cardProperties.findIndex((o) => o.id === propertyId)
const newBoard = createBoard(board)
const changedBlocks: Block[] = [newBoard]
const index = newBoard.fields.cardProperties.findIndex((o: IPropertyTemplate) => o.id === propertyId)
if (index === -1) {
Utils.assertFailure(`Cannot find template with id: ${propertyId}`)
return
}
const srcTemplate = newBoard.cardProperties[index]
const srcTemplate = newBoard.fields.cardProperties[index]
const newTemplate: IPropertyTemplate = {
id: Utils.createGuid(),
name: `${srcTemplate.name} copy`,
type: srcTemplate.type,
options: srcTemplate.options.slice(),
}
newBoard.cardProperties.splice(index + 1, 0, newTemplate)
newBoard.fields.cardProperties.splice(index + 1, 0, newTemplate)
let description = 'duplicate property'
if (activeView.viewType === 'table') {
if (activeView.fields.viewType === 'table') {
oldBlocks.push(activeView)
const newActiveView = new MutableBoardView(activeView)
newActiveView.visiblePropertyIds.push(newTemplate.id)
const newActiveView = createBoardView(activeView)
newActiveView.fields.visiblePropertyIds.push(newTemplate.id)
changedBlocks.push(newActiveView)
description = 'duplicate column'
@ -255,43 +253,41 @@ class Mutator {
}
async changePropertyTemplateOrder(board: Board, template: IPropertyTemplate, destIndex: number) {
const templates = board.cardProperties
const templates = board.fields.cardProperties
const newValue = templates.slice()
const srcIndex = templates.indexOf(template)
Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`)
newValue.splice(destIndex, 0, newValue.splice(srcIndex, 1)[0])
const newBoard = new MutableBoard(board)
newBoard.cardProperties = newValue
const newBoard = createBoard(board)
newBoard.fields.cardProperties = newValue
await this.updateBlock(newBoard, board, 'reorder properties')
}
async deleteProperty(boardTree: BoardTree, propertyId: string) {
const {board, views, cards} = boardTree
async deleteProperty(board: Board, views: BoardView[], cards: Card[], propertyId: string) {
const oldBlocks: Block[] = [board]
const oldBlocks: IBlock[] = [board]
const newBoard = new MutableBoard(board)
const changedBlocks: IBlock[] = [newBoard]
newBoard.cardProperties = board.cardProperties.filter((o) => o.id !== propertyId)
const newBoard = createBoard(board)
const changedBlocks: Block[] = [newBoard]
newBoard.fields.cardProperties = board.fields.cardProperties.filter((o: IPropertyTemplate) => o.id !== propertyId)
views.forEach((view) => {
if (view.visiblePropertyIds.includes(propertyId)) {
if (view.fields.visiblePropertyIds.includes(propertyId)) {
oldBlocks.push(view)
const newView = new MutableBoardView(view)
newView.visiblePropertyIds = view.visiblePropertyIds.filter((o) => o !== propertyId)
const newView = createBoardView(view)
newView.fields.visiblePropertyIds = view.fields.visiblePropertyIds.filter((o: string) => o !== propertyId)
changedBlocks.push(newView)
}
})
cards.forEach((card) => {
if (card.properties[propertyId]) {
if (card.fields.properties[propertyId]) {
oldBlocks.push(card)
const newCard = new MutableCard(card)
delete newCard.properties[propertyId]
const newCard = createCard(card)
delete newCard.fields.properties[propertyId]
changedBlocks.push(newCard)
}
})
@ -301,23 +297,19 @@ class Mutator {
// Properties
async insertPropertyOption(boardTree: BoardTree, template: IPropertyTemplate, option: IPropertyOption, description = 'add option') {
const {board} = boardTree
async insertPropertyOption(board: Board, template: IPropertyTemplate, option: IPropertyOption, description = 'add option') {
Utils.assert(board.fields.cardProperties.includes(template))
Utils.assert(board.cardProperties.includes(template))
const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)!
const newBoard = createBoard(board)
const newTemplate = newBoard.fields.cardProperties.find((o: IPropertyTemplate) => o.id === template.id)!
newTemplate.options.push(option)
await this.updateBlock(newBoard, board, description)
}
async deletePropertyOption(boardTree: BoardTree, template: IPropertyTemplate, option: IPropertyOption) {
const {board} = boardTree
const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)!
async deletePropertyOption(board: Board, template: IPropertyTemplate, option: IPropertyOption) {
const newBoard = createBoard(board)
const newTemplate = newBoard.fields.cardProperties.find((o: IPropertyTemplate) => o.id === template.id)!
newTemplate.options = newTemplate.options.filter((o) => o.id !== option.id)
await this.updateBlock(newBoard, board, 'delete option')
@ -327,23 +319,21 @@ class Mutator {
const srcIndex = template.options.indexOf(option)
Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`)
const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)!
const newBoard = createBoard(board)
const newTemplate = newBoard.fields.cardProperties.find((o: IPropertyTemplate) => o.id === template.id)!
newTemplate.options.splice(destIndex, 0, newTemplate.options.splice(srcIndex, 1)[0])
await this.updateBlock(newBoard, board, 'reorder options')
}
async changePropertyOptionValue(boardTree: BoardTree, propertyTemplate: IPropertyTemplate, option: IPropertyOption, value: string) {
const {board} = boardTree
async changePropertyOptionValue(board: Board, propertyTemplate: IPropertyTemplate, option: IPropertyOption, value: string) {
const oldBlocks: Block[] = [board]
const oldBlocks: IBlock[] = [board]
const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)!
const newBoard = createBoard(board)
const newTemplate = newBoard.fields.cardProperties.find((o: IPropertyTemplate) => o.id === propertyTemplate.id)!
const newOption = newTemplate.options.find((o) => o.id === option.id)!
newOption.value = value
const changedBlocks: IBlock[] = [newBoard]
const changedBlocks: Block[] = [newBoard]
await this.updateBlocks(changedBlocks, oldBlocks, 'rename option')
@ -351,32 +341,30 @@ class Mutator {
}
async changePropertyOptionColor(board: Board, template: IPropertyTemplate, option: IPropertyOption, color: string) {
const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)!
const newBoard = createBoard(board)
const newTemplate = newBoard.fields.cardProperties.find((o: IPropertyTemplate) => o.id === template.id)!
const newOption = newTemplate.options.find((o) => o.id === option.id)!
newOption.color = color
await this.updateBlock(newBoard, board, 'change option color')
}
async changePropertyValue(card: Card, propertyId: string, value?: string | string[], description = 'change property') {
const newCard = new MutableCard(card)
const newCard = createCard(card)
if (value) {
newCard.properties[propertyId] = value
newCard.fields.properties[propertyId] = value
} else {
delete newCard.properties[propertyId]
delete newCard.fields.properties[propertyId]
}
await this.updateBlock(newCard, card, description)
}
async changePropertyTypeAndName(boardTree: BoardTree, propertyTemplate: IPropertyTemplate, newType: PropertyType, newName: string) {
async changePropertyTypeAndName(board: Board, cards: Card[], propertyTemplate: IPropertyTemplate, newType: PropertyType, newName: string) {
if (propertyTemplate.type === newType && propertyTemplate.name === newName) {
return
}
const {board} = boardTree
const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)!
const newBoard = createBoard(board)
const newTemplate = newBoard.fields.cardProperties.find((o: IPropertyTemplate) => o.id === propertyTemplate.id)!
if (propertyTemplate.type !== newType) {
newTemplate.options = []
@ -385,25 +373,24 @@ class Mutator {
newTemplate.type = newType
newTemplate.name = newName
const oldBlocks: IBlock[] = [board]
const newBlocks: IBlock[] = [newBoard]
const oldBlocks: Block[] = [board]
const newBlocks: Block[] = [newBoard]
if (propertyTemplate.type !== newType) {
if (propertyTemplate.type === 'select' || propertyTemplate.type === 'multiSelect') { // If the old type was either select or multiselect
const isNewTypeSelectOrMulti = newType === 'select' || newType === 'multiSelect'
for (const card of boardTree.allCards) {
const oldValue = Array.isArray(card.properties[propertyTemplate.id]) ? (card.properties[propertyTemplate.id].length > 0 && card.properties[propertyTemplate.id][0]) : card.properties[propertyTemplate.id]
for (const card of cards) {
const oldValue = Array.isArray(card.fields.properties[propertyTemplate.id]) ? (card.fields.properties[propertyTemplate.id].length > 0 && card.fields.properties[propertyTemplate.id][0]) : card.fields.properties[propertyTemplate.id]
if (oldValue) {
const newValue = isNewTypeSelectOrMulti ? propertyTemplate.options.find((o) => o.id === oldValue)?.id : propertyTemplate.options.find((o) => o.id === oldValue)?.value
const newCard = new MutableCard(card)
const newCard = createCard(card)
if (newValue) {
newCard.properties[propertyTemplate.id] = newType === 'multiSelect' ? [newValue] : newValue
newCard.fields.properties[propertyTemplate.id] = newType === 'multiSelect' ? [newValue] : newValue
} else {
// This was an invalid select option, so delete it
delete newCard.properties[propertyTemplate.id]
delete newCard.fields.properties[propertyTemplate.id]
}
newBlocks.push(newCard)
@ -416,10 +403,10 @@ class Mutator {
}
} else if (newType === 'select' || newType === 'multiSelect') { // if the new type is either select or multiselect
// Map values to new template option IDs
for (const card of boardTree.allCards) {
const oldValue = card.properties[propertyTemplate.id] as string
for (const card of cards) {
const oldValue = card.fields.properties[propertyTemplate.id] as string
if (oldValue) {
let option = newTemplate.options.find((o) => o.value === oldValue)
let option = newTemplate.options.find((o: IPropertyOption) => o.value === oldValue)
if (!option) {
option = {
id: Utils.createGuid(),
@ -429,8 +416,8 @@ class Mutator {
newTemplate.options.push(option)
}
const newCard = new MutableCard(card)
newCard.properties[propertyTemplate.id] = newType === 'multiSelect' ? [option.id] : option.id
const newCard = createCard(card)
newCard.fields.properties[propertyTemplate.id] = newType === 'multiSelect' ? [option.id] : option.id
newBlocks.push(newCard)
oldBlocks.push(card)
@ -445,71 +432,71 @@ class Mutator {
// Views
async changeViewSortOptions(view: BoardView, sortOptions: ISortOption[]): Promise<void> {
const newView = new MutableBoardView(view)
newView.sortOptions = sortOptions
const newView = createBoardView(view)
newView.fields.sortOptions = sortOptions
await this.updateBlock(newView, view, 'sort')
}
async changeViewFilter(view: BoardView, filter: FilterGroup): Promise<void> {
const newView = new MutableBoardView(view)
newView.filter = filter
const newView = createBoardView(view)
newView.fields.filter = filter
await this.updateBlock(newView, view, 'filter')
}
async changeViewGroupById(view: BoardView, groupById: string): Promise<void> {
const newView = new MutableBoardView(view)
newView.groupById = groupById
newView.hiddenOptionIds = []
newView.visibleOptionIds = []
const newView = createBoardView(view)
newView.fields.groupById = groupById
newView.fields.hiddenOptionIds = []
newView.fields.visibleOptionIds = []
await this.updateBlock(newView, view, 'group by')
}
async changeViewVisibleProperties(view: BoardView, visiblePropertyIds: string[], description = 'show / hide property'): Promise<void> {
const newView = new MutableBoardView(view)
newView.visiblePropertyIds = visiblePropertyIds
const newView = createBoardView(view)
newView.fields.visiblePropertyIds = visiblePropertyIds
await this.updateBlock(newView, view, description)
}
async changeViewVisibleOptionIds(view: BoardView, visibleOptionIds: string[], description = 'reorder'): Promise<void> {
const newView = new MutableBoardView(view)
newView.visibleOptionIds = visibleOptionIds
const newView = createBoardView(view)
newView.fields.visibleOptionIds = visibleOptionIds
await this.updateBlock(newView, view, description)
}
async changeViewHiddenOptionIds(view: BoardView, hiddenOptionIds: string[], description = 'reorder'): Promise<void> {
const newView = new MutableBoardView(view)
newView.hiddenOptionIds = hiddenOptionIds
const newView = createBoardView(view)
newView.fields.hiddenOptionIds = hiddenOptionIds
await this.updateBlock(newView, view, description)
}
async hideViewColumn(view: BoardView, columnOptionId: string): Promise<void> {
if (view.hiddenOptionIds.includes(columnOptionId)) {
if (view.fields.hiddenOptionIds.includes(columnOptionId)) {
return
}
const newView = new MutableBoardView(view)
newView.visibleOptionIds = newView.visibleOptionIds.filter((o) => o !== columnOptionId)
newView.hiddenOptionIds.push(columnOptionId)
const newView = createBoardView(view)
newView.fields.visibleOptionIds = newView.fields.visibleOptionIds.filter((o) => o !== columnOptionId)
newView.fields.hiddenOptionIds = [...newView.fields.hiddenOptionIds, columnOptionId]
await this.updateBlock(newView, view, 'hide column')
}
async unhideViewColumn(view: BoardView, columnOptionId: string): Promise<void> {
if (!view.hiddenOptionIds.includes(columnOptionId)) {
if (!view.fields.hiddenOptionIds.includes(columnOptionId)) {
return
}
const newView = new MutableBoardView(view)
newView.hiddenOptionIds = newView.hiddenOptionIds.filter((o) => o !== columnOptionId)
const newView = createBoardView(view)
newView.fields.hiddenOptionIds = newView.fields.hiddenOptionIds.filter((o) => o !== columnOptionId)
// Put the column at the end of the visible list
newView.visibleOptionIds = newView.visibleOptionIds.filter((o) => o !== columnOptionId)
newView.visibleOptionIds.push(columnOptionId)
newView.fields.visibleOptionIds = newView.fields.visibleOptionIds.filter((o) => o !== columnOptionId)
newView.fields.visibleOptionIds = [...newView.fields.visibleOptionIds, columnOptionId]
await this.updateBlock(newView, view, 'show column')
}
async changeViewCardOrder(view: BoardView, cardOrder: string[], description = 'reorder'): Promise<void> {
const newView = new MutableBoardView(view)
newView.cardOrder = cardOrder
const newView = createBoardView(view)
newView.fields.cardOrder = cardOrder
await this.updateBlock(newView, view, description)
}
@ -521,12 +508,12 @@ class Mutator {
asTemplate = false,
afterRedo?: (newCardId: string) => Promise<void>,
beforeUndo?: () => Promise<void>,
): Promise<[IBlock[], string]> {
): Promise<[Block[], string]> {
const blocks = await octoClient.getSubtree(cardId, 2)
const [newBlocks1, newCard] = OctoUtils.duplicateBlockTree(blocks, cardId) as [IBlock[], MutableCard, Record<string, string>]
const [newBlocks1, newCard] = OctoUtils.duplicateBlockTree(blocks, cardId) as [Block[], Card, Record<string, string>]
const newBlocks = newBlocks1.filter((o) => o.type !== 'comment')
Utils.log(`duplicateCard: duplicating ${newBlocks.length} blocks`)
if (asTemplate === newCard.isTemplate) {
if (asTemplate === newCard.fields.isTemplate) {
// Copy template
newCard.title = `${newCard.title} copy`
} else if (asTemplate) {
@ -537,11 +524,11 @@ class Mutator {
newCard.title = ''
// If the template doesn't specify an icon, initialize it to a random one
if (!newCard.icon) {
newCard.icon = BlockIcons.shared.randomIcon()
if (!newCard.fields.icon) {
newCard.fields.icon = BlockIcons.shared.randomIcon()
}
}
newCard.isTemplate = asTemplate
newCard.fields.isTemplate = asTemplate
await this.insertBlocks(
newBlocks,
description,
@ -559,13 +546,13 @@ class Mutator {
asTemplate = false,
afterRedo?: (newBoardId: string) => Promise<void>,
beforeUndo?: () => Promise<void>,
): Promise<[IBlock[], string]> {
): Promise<[Block[], string]> {
const blocks = await octoClient.getSubtree(boardId, 3)
const [newBlocks1, newBoard] = OctoUtils.duplicateBlockTree(blocks, boardId) as [IBlock[], MutableBoard, Record<string, string>]
const [newBlocks1, newBoard] = OctoUtils.duplicateBlockTree(blocks, boardId) as [Block[], Board, Record<string, string>]
const newBlocks = newBlocks1.filter((o) => o.type !== 'comment')
Utils.log(`duplicateBoard: duplicating ${newBlocks.length} blocks`)
if (asTemplate === newBoard.isTemplate) {
if (asTemplate === newBoard.fields.isTemplate) {
newBoard.title = `${newBoard.title} copy`
} else if (asTemplate) {
// Template from board
@ -573,7 +560,7 @@ class Mutator {
} else {
// Board from template
}
newBoard.isTemplate = asTemplate
newBoard.fields.isTemplate = asTemplate
await this.insertBlocks(
newBlocks,
description,
@ -591,14 +578,14 @@ class Mutator {
asTemplate = false,
afterRedo?: (newBoardId: string) => Promise<void>,
beforeUndo?: () => Promise<void>,
): Promise<[IBlock[], string]> {
): Promise<[Block[], string]> {
const rootClient = new OctoClient(octoClient.serverUrl, '0')
const blocks = await rootClient.getSubtree(boardId, 3)
const [newBlocks1, newBoard] = OctoUtils.duplicateBlockTree(blocks, boardId) as [IBlock[], MutableBoard, Record<string, string>]
const [newBlocks1, newBoard] = OctoUtils.duplicateBlockTree(blocks, boardId) as [Block[], Board, Record<string, string>]
const newBlocks = newBlocks1.filter((o) => o.type !== 'comment')
Utils.log(`duplicateBoard: duplicating ${newBlocks.length} blocks`)
if (asTemplate === newBoard.isTemplate) {
if (asTemplate === newBoard.fields.isTemplate) {
newBoard.title = `${newBoard.title} copy`
} else if (asTemplate) {
// Template from board
@ -606,7 +593,7 @@ class Mutator {
} else {
// Board from template
}
newBoard.isTemplate = asTemplate
newBoard.fields.isTemplate = asTemplate
await this.insertBlocks(
newBlocks,
description,
@ -621,12 +608,12 @@ class Mutator {
// Other methods
// Not a mutator, but convenient to put here since Mutator wraps OctoClient
async exportArchive(boardID?: string): Promise<IBlock[]> {
async exportArchive(boardID?: string): Promise<Block[]> {
return octoClient.exportArchive(boardID)
}
// Not a mutator, but convenient to put here since Mutator wraps OctoClient
async importFullArchive(blocks: readonly IBlock[]): Promise<Response> {
async importFullArchive(blocks: readonly Block[]): Promise<Response> {
return octoClient.importFullArchive(blocks)
}

View File

@ -4,8 +4,8 @@
// Disable console log
console.log = jest.fn()
import {IBlock} from './blocks/block'
import {MutableBoard} from './blocks/board'
import {Block} from './blocks/block'
import {createBoard} from './blocks/board'
import octoClient from './octoClient'
import 'isomorphic-fetch'
import {FetchMock} from './test/fetchMock'
@ -69,11 +69,11 @@ test('OctoClient: importFullArchive', async () => {
}))
})
function createBoards(): IBlock[] {
function createBoards(): Block[] {
const blocks = []
for (let i = 0; i < 5; i++) {
const board = new MutableBoard()
const board = createBoard()
board.id = `board${i + 1}`
blocks.push(board)
}

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