mirror of
https://github.com/mattermost/focalboard.git
synced 2024-11-24 08:22:29 +02:00
Initial Boards+Channels implementation (#3110)
* Initial Boards+Channels implementation * Adding draft code to list the boards in a channel * Adding the hability to link/unlink channels (fake channel for now) * Simplify slight the migrations * WIP * More changes to improve the implementation * Adding partial implementation of linking channel from board * Allow linking in both directions * Removing unused file * More work on channel binding * some refactoring * Improving code quality and interface * More improvements * Changing the API to search channels * Adding a limit of 10 channels in search * Add confirmation on linking public channels * Improve a bit the styling of the confirmation modal * Showing the current linked channel * Adding link board confirmation to channel interface * Fixing tests and linter errors * Fixing backend tests * Adding permissions tests * Fixing linter errors * Fixing small things * Fixing some typescript errors * Adding new boardSelectorItem tests * Improving a bit tests * Adding jest unit tests * Remove duplicated implementation (from merge, I guess) * Adding missed files * Addressing some of the PR review comments * Removing unneeded new wrapIntl implementation * Moving NotSupportedError to the store package to be share between all the store implementations or layers * Fixing one of the pendings ToDo * Creating a constructor for the NotSupportedError * Fixing linter error
This commit is contained in:
parent
685d74a817
commit
46fdbf9048
BIN
mattermost-plugin/public/boards-screenshots.png
Normal file
BIN
mattermost-plugin/public/boards-screenshots.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 196 KiB |
34
mattermost-plugin/webapp/package-lock.json
generated
34
mattermost-plugin/webapp/package-lock.json
generated
@ -30,8 +30,10 @@
|
||||
"@babel/runtime": "7.17.8",
|
||||
"@formatjs/ts-transformer": "3.9.2",
|
||||
"@testing-library/react": "11.2.7",
|
||||
"@testing-library/user-event": "14.2.1",
|
||||
"@types/enzyme": "3.10.11",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/lodash": "4.14.182",
|
||||
"@types/node": "17.0.23",
|
||||
"@types/react": "17.0.42",
|
||||
"@types/react-dom": "17.0.14",
|
||||
@ -4275,6 +4277,19 @@
|
||||
"react-dom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.2.1.tgz",
|
||||
"integrity": "sha512-HOr1QiODrq+0j9lKU5i10y9TbhxMBMRMGimNx10asdmau9cb8Xb1Vyg0GvTwyIL2ziQyh2kAloOtAQFBQVuecA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": ">=7.21.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
||||
@ -4493,6 +4508,12 @@
|
||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.14.182",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
|
||||
"integrity": "sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/minimatch": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
||||
@ -24185,6 +24206,13 @@
|
||||
"@testing-library/dom": "^7.28.1"
|
||||
}
|
||||
},
|
||||
"@testing-library/user-event": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.2.1.tgz",
|
||||
"integrity": "sha512-HOr1QiODrq+0j9lKU5i10y9TbhxMBMRMGimNx10asdmau9cb8Xb1Vyg0GvTwyIL2ziQyh2kAloOtAQFBQVuecA==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@tootallnate/once": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
||||
@ -24390,6 +24418,12 @@
|
||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.14.182",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
|
||||
"integrity": "sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/minimatch": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
||||
|
@ -27,8 +27,10 @@
|
||||
"@babel/runtime": "7.17.8",
|
||||
"@formatjs/ts-transformer": "3.9.2",
|
||||
"@testing-library/react": "11.2.7",
|
||||
"@testing-library/user-event": "14.2.1",
|
||||
"@types/enzyme": "3.10.11",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/lodash": "4.14.182",
|
||||
"@types/node": "17.0.23",
|
||||
"@types/react": "17.0.42",
|
||||
"@types/react-dom": "17.0.14",
|
||||
@ -106,9 +108,12 @@
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "identity-obj-proxy",
|
||||
"^.+\\.(css|less|scss)$": "identity-obj-proxy",
|
||||
"^.+\\.(scss|css)$": "<rootDir>/tests/style_mock.json",
|
||||
"^.*i18n.*\\.(json)$": "<rootDir>/tests/i18n_mock.json",
|
||||
"^bundle-loader\\?lazy\\!(.*)$": "$1"
|
||||
"^bundle-loader\\?lazy\\!(.*)$": "$1",
|
||||
"^react$": "<rootDir>/../../webapp/node_modules/react",
|
||||
"^react-redux$": "<rootDir>/../../webapp/node_modules/react-redux",
|
||||
"^react-intl$": "<rootDir>/../../webapp/node_modules/react-intl"
|
||||
},
|
||||
"moduleDirectories": [
|
||||
"",
|
||||
|
@ -0,0 +1,383 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/boardSelector renders with no results 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="focalboard-body"
|
||||
>
|
||||
<div
|
||||
class="Dialog dialog-back BoardSelector"
|
||||
>
|
||||
<div
|
||||
class="backdrop"
|
||||
/>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="toolbar"
|
||||
>
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
class="IconButton size--medium"
|
||||
title="Close dialog"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="toolbar--right"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="BoardSelectorBody"
|
||||
>
|
||||
<div
|
||||
class="head"
|
||||
>
|
||||
<div
|
||||
class="heading"
|
||||
>
|
||||
<h3
|
||||
class="text-heading4"
|
||||
>
|
||||
Link boards
|
||||
</h3>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Create a board
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="queryWrapper"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-magnify MagnifyIcon"
|
||||
/>
|
||||
<input
|
||||
class="searchQuery"
|
||||
maxlength="100"
|
||||
placeholder="Search for boards"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="searchResults"
|
||||
>
|
||||
<div
|
||||
class="noResults"
|
||||
>
|
||||
<div
|
||||
class="iconWrapper"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-magnify MagnifyIcon"
|
||||
/>
|
||||
</div>
|
||||
<h4
|
||||
class="text-heading4"
|
||||
>
|
||||
No results for "test"
|
||||
</h4>
|
||||
<span>
|
||||
Check the spelling or try another search.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/boardSelector renders with some results 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="focalboard-body"
|
||||
>
|
||||
<div
|
||||
class="Dialog dialog-back BoardSelector"
|
||||
>
|
||||
<div
|
||||
class="backdrop"
|
||||
/>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="toolbar"
|
||||
>
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
class="IconButton size--medium"
|
||||
title="Close dialog"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="toolbar--right"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="BoardSelectorBody"
|
||||
>
|
||||
<div
|
||||
class="head"
|
||||
>
|
||||
<div
|
||||
class="heading"
|
||||
>
|
||||
<h3
|
||||
class="text-heading4"
|
||||
>
|
||||
Link boards
|
||||
</h3>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Create a board
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="queryWrapper"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-magnify MagnifyIcon"
|
||||
/>
|
||||
<input
|
||||
class="searchQuery"
|
||||
maxlength="100"
|
||||
placeholder="Search for boards"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="searchResults"
|
||||
>
|
||||
<div
|
||||
class="BoardSelectorItem"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="resultLine"
|
||||
>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Untitled board
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--primary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="BoardSelectorItem"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="resultLine"
|
||||
>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Untitled board
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--primary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="BoardSelectorItem"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="resultLine"
|
||||
>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Untitled board
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--primary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/boardSelector renders without start searching 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="focalboard-body"
|
||||
>
|
||||
<div
|
||||
class="Dialog dialog-back BoardSelector"
|
||||
>
|
||||
<div
|
||||
class="backdrop"
|
||||
/>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="toolbar"
|
||||
>
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
class="IconButton size--medium"
|
||||
title="Close dialog"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="toolbar--right"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="BoardSelectorBody"
|
||||
>
|
||||
<div
|
||||
class="head"
|
||||
>
|
||||
<div
|
||||
class="heading"
|
||||
>
|
||||
<h3
|
||||
class="text-heading4"
|
||||
>
|
||||
Link boards
|
||||
</h3>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Create a board
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="queryWrapper"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-magnify MagnifyIcon"
|
||||
/>
|
||||
<input
|
||||
class="searchQuery"
|
||||
maxlength="100"
|
||||
placeholder="Search for boards"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="searchResults"
|
||||
>
|
||||
|
||||
|
||||
<div
|
||||
class="noResults introScreen"
|
||||
>
|
||||
<div
|
||||
class="iconWrapper"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-magnify MagnifyIcon"
|
||||
/>
|
||||
</div>
|
||||
<h4
|
||||
class="text-heading4"
|
||||
>
|
||||
Search for boards
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -0,0 +1,109 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/boardSelectorItem renders board without title 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="BoardSelectorItem"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="resultLine"
|
||||
>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Untitled board
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Unlink
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/boardSelectorItem renders linked board 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="BoardSelectorItem"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="resultLine"
|
||||
>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Test title
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Unlink
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/boardSelectorItem renders not linked board 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="BoardSelectorItem"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="resultLine"
|
||||
>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Test title
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--primary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -0,0 +1,142 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/rhsChannelBoardItem render board 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="RHSChannelBoardItem"
|
||||
>
|
||||
<div
|
||||
class="board-info"
|
||||
>
|
||||
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Test board
|
||||
</span>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-dots-horizontal OptionsIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
<div
|
||||
class="date"
|
||||
>
|
||||
Last update at: July 06, 8:48 AM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/rhsChannelBoardItem render board with menu open 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="RHSChannelBoardItem"
|
||||
>
|
||||
<div
|
||||
class="board-info"
|
||||
>
|
||||
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Test board
|
||||
</span>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-dots-horizontal OptionsIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="Menu noselect left fixed"
|
||||
>
|
||||
<div
|
||||
class="menu-contents"
|
||||
>
|
||||
<div
|
||||
class="menu-options"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
aria-label="Unlink board"
|
||||
class="MenuOption TextOption menu-option"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-trash-can-outline DeleteIcon trash-can-outline"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Unlink board
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="menu-spacer hideOnWidescreen"
|
||||
/>
|
||||
<div
|
||||
class="menu-options hideOnWidescreen"
|
||||
>
|
||||
<div
|
||||
aria-label="Cancel"
|
||||
class="MenuOption TextOption menu-option menu-cancel"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Cancel
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
<div
|
||||
class="date"
|
||||
>
|
||||
Last update at: July 06, 8:48 AM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -0,0 +1,142 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/rhsChannelBoards renders the RHS for channel boards 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="focalboard-body"
|
||||
>
|
||||
<div
|
||||
class="RHSChannelBoards"
|
||||
>
|
||||
<div
|
||||
class="rhs-boards-header"
|
||||
>
|
||||
<span
|
||||
class="linked-boards"
|
||||
>
|
||||
Linked boards
|
||||
</span>
|
||||
<button
|
||||
class="Button emphasis--primary"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-plus AddIcon"
|
||||
/>
|
||||
<span>
|
||||
Add
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="rhs-boards-list"
|
||||
>
|
||||
<div
|
||||
class="RHSChannelBoardItem"
|
||||
>
|
||||
<div
|
||||
class="board-info"
|
||||
>
|
||||
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Untitled board
|
||||
</span>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-dots-horizontal OptionsIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
<div
|
||||
class="date"
|
||||
>
|
||||
Last update at: July 06, 8:48 AM
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="RHSChannelBoardItem"
|
||||
>
|
||||
<div
|
||||
class="board-info"
|
||||
>
|
||||
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Untitled board
|
||||
</span>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-dots-horizontal OptionsIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
<div
|
||||
class="date"
|
||||
>
|
||||
Last update at: July 06, 8:48 AM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/rhsChannelBoards renders with empty list of boards 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="focalboard-body"
|
||||
>
|
||||
<div
|
||||
class="RHSChannelBoards empty"
|
||||
>
|
||||
<h2>
|
||||
No boards are linked to Channel Name yet
|
||||
</h2>
|
||||
<div
|
||||
class="empty-paragraph"
|
||||
>
|
||||
Boards is a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view.
|
||||
</div>
|
||||
<div
|
||||
class="boards-screenshots"
|
||||
>
|
||||
<img
|
||||
src="undefined/public/boards-screenshots.png"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="Button emphasis--primary size--medium"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Link boards to Channel Name
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -0,0 +1,20 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/rhsChannelBoardsHeader renders the header 1`] = `
|
||||
<div>
|
||||
<div>
|
||||
<img
|
||||
class="boards-rhs-header-logo"
|
||||
src="undefined/public/app-bar-icon.png"
|
||||
/>
|
||||
<span>
|
||||
Boards
|
||||
</span>
|
||||
<span
|
||||
class="style--none sidebar--right__title__subtitle"
|
||||
>
|
||||
Channel Name
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
166
mattermost-plugin/webapp/src/components/boardSelector.scss
Normal file
166
mattermost-plugin/webapp/src/components/boardSelector.scss
Normal file
@ -0,0 +1,166 @@
|
||||
.BoardSelector {
|
||||
color: rgba(var(--center-channel-color-rgb));
|
||||
|
||||
.dialog {
|
||||
.toolbar {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 35px;
|
||||
margin-top: 5px;
|
||||
|
||||
.text-heading4 {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
.dialog {
|
||||
position: relative;
|
||||
width: 600px;
|
||||
height: 450px;
|
||||
|
||||
.toolbar {
|
||||
flex-direction: row-reverse;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: 18px;
|
||||
}
|
||||
}
|
||||
.confirmation-dialog-box {
|
||||
.dialog {
|
||||
position: fixed;
|
||||
width: 500px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.BoardSelectorBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.head {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.head,
|
||||
.searchResults {
|
||||
padding: 0 32px 32px;
|
||||
}
|
||||
|
||||
.searchResults {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
margin-bottom: 18px;
|
||||
border-top: solid 1px rgba(var(--center-channel-color-rgb), 0.16);
|
||||
|
||||
.searchResult {
|
||||
height: 40px;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 0 24px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
&.freesize {
|
||||
height: unset;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
/*! align-content: center; */
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.CompassIcon.icon-magnify.MagnifyIcon {
|
||||
font-size: 72px !important;
|
||||
color: var(--button-bg);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.noResults {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 500px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
word-wrap: anywhere;
|
||||
margin-top: 30px;
|
||||
|
||||
.text-heading4 {
|
||||
line-height: 120%;
|
||||
}
|
||||
|
||||
&.introScreen {
|
||||
margin-top: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-heading1 {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 12px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.queryWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 24px;
|
||||
|
||||
.MagnifyIcon {
|
||||
position: absolute;
|
||||
left: 13px;
|
||||
font-size: 18px;
|
||||
top: 14px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.searchQuery {
|
||||
height: 48px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
background: var(--center-channel-bg);
|
||||
color: var(--center-channel-color);
|
||||
padding: 0 40px;
|
||||
flex: 1;
|
||||
transition: border 0.15s ease-in;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--button-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--button-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {Provider as ReduxProvider} from 'react-redux'
|
||||
import {render, screen, act} from '@testing-library/react'
|
||||
import {mocked} from 'jest-mock'
|
||||
|
||||
import octoClient from '../../../../webapp/src/octoClient'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {mockStateStore} from '../../../../webapp/src/testUtils'
|
||||
import {createBoard} from '../../../../webapp/src/blocks/board'
|
||||
import {wrapIntl} from '../../../../webapp/src/testUtils'
|
||||
|
||||
import BoardSelector from './boardSelector'
|
||||
|
||||
jest.mock('../../../../webapp/src/octoClient')
|
||||
const mockedOctoClient = mocked(octoClient, true)
|
||||
|
||||
const wait = (ms: number) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
describe('components/boardSelector', () => {
|
||||
const team = {
|
||||
id: 'team-id',
|
||||
name: 'team',
|
||||
display_name: 'Team name',
|
||||
}
|
||||
const state = {
|
||||
teams: {
|
||||
allTeams: [team],
|
||||
current: team,
|
||||
},
|
||||
language: {
|
||||
value: 'en',
|
||||
},
|
||||
boards: {
|
||||
linkToChannel: 'channel-id',
|
||||
},
|
||||
}
|
||||
|
||||
it('renders without start searching', async () => {
|
||||
const store = mockStateStore([], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<BoardSelector/>
|
||||
</ReduxProvider>
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders with no results', async () => {
|
||||
mockedOctoClient.search.mockResolvedValueOnce([])
|
||||
|
||||
const store = mockStateStore([], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<BoardSelector/>
|
||||
</ReduxProvider>
|
||||
))
|
||||
|
||||
await act(async () => {
|
||||
const inputElement = screen.getByPlaceholderText('Search for boards')
|
||||
await userEvent.type(inputElement, 'test')
|
||||
await wait(300)
|
||||
})
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders with some results', async () => {
|
||||
mockedOctoClient.search.mockResolvedValueOnce([createBoard(), createBoard(), createBoard()])
|
||||
|
||||
const store = mockStateStore([], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<BoardSelector/>
|
||||
</ReduxProvider>
|
||||
))
|
||||
|
||||
await act(async () => {
|
||||
const inputElement = screen.getByPlaceholderText('Search for boards')
|
||||
await userEvent.type(inputElement, 'test')
|
||||
await wait(300)
|
||||
})
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
207
mattermost-plugin/webapp/src/components/boardSelector.tsx
Normal file
207
mattermost-plugin/webapp/src/components/boardSelector.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useState, useMemo, useCallback} from 'react'
|
||||
import {IntlProvider, useIntl, FormattedMessage} from 'react-intl'
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
import {getMessages} from '../../../../webapp/src/i18n'
|
||||
import {getLanguage} from '../../../../webapp/src/store/language'
|
||||
|
||||
import octoClient from '../../../../webapp/src/octoClient'
|
||||
import mutator from '../../../../webapp/src/mutator'
|
||||
import {getCurrentTeam, getAllTeams, Team} from '../../../../webapp/src/store/teams'
|
||||
import {createBoard, BoardsAndBlocks, Board} from '../../../../webapp/src/blocks/board'
|
||||
import {createBoardView} from '../../../../webapp/src/blocks/boardView'
|
||||
import {useAppSelector, useAppDispatch} from '../../../../webapp/src/store/hooks'
|
||||
import {EmptySearch, EmptyResults} from '../../../../webapp/src/components/searchDialog/searchDialog'
|
||||
import ConfirmationDialog from '../../../../webapp/src/components/confirmationDialogBox'
|
||||
import Dialog from '../../../../webapp/src/components/dialog'
|
||||
import SearchIcon from '../../../../webapp/src/widgets/icons/search'
|
||||
import Button from '../../../../webapp/src/widgets/buttons/button'
|
||||
import {getCurrentLinkToChannel, setLinkToChannel} from '../../../../webapp/src/store/boards'
|
||||
import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../../../webapp/src/telemetry/telemetryClient'
|
||||
|
||||
import BoardSelectorItem from './boardSelectorItem'
|
||||
|
||||
import './boardSelector.scss'
|
||||
|
||||
const BoardSelector = () => {
|
||||
const teamsById:Record<string, Team> = {}
|
||||
useAppSelector(getAllTeams).forEach((t) => {
|
||||
teamsById[t.id] = t
|
||||
})
|
||||
const intl = useIntl()
|
||||
const team = useAppSelector(getCurrentTeam)
|
||||
const currentChannel = useAppSelector(getCurrentLinkToChannel)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [results, setResults] = useState<Array<Board>>([])
|
||||
const [isSearching, setIsSearching] = useState<boolean>(false)
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
const [showLinkBoardConfirmation, setShowLinkBoardConfirmation] = useState<Board|null>(null)
|
||||
|
||||
const searchHandler = useCallback(async (query: string): Promise<void> => {
|
||||
setSearchQuery(query)
|
||||
|
||||
if (query.trim().length === 0 || !team) {
|
||||
return
|
||||
}
|
||||
const items = await octoClient.search(team.id, query)
|
||||
|
||||
setResults(items)
|
||||
setIsSearching(false)
|
||||
}, [team?.id])
|
||||
|
||||
const debouncedSearchHandler = useMemo(() => debounce(searchHandler, 200), [searchHandler])
|
||||
|
||||
const emptyResult = results.length === 0 && !isSearching && searchQuery
|
||||
|
||||
if (!team) {
|
||||
return null
|
||||
}
|
||||
if (!currentChannel) {
|
||||
return null
|
||||
}
|
||||
|
||||
const linkBoard = async (board: Board, confirmed?: boolean): Promise<void> => {
|
||||
if (!confirmed) {
|
||||
setShowLinkBoardConfirmation(board)
|
||||
return
|
||||
}
|
||||
const newBoard = createBoard(board)
|
||||
newBoard.channelId = currentChannel
|
||||
await mutator.updateBoard(newBoard, board, 'linked channel')
|
||||
for (const result of results) {
|
||||
if (result.id == board.id) {
|
||||
result.channelId = currentChannel
|
||||
setResults([...results])
|
||||
}
|
||||
}
|
||||
setShowLinkBoardConfirmation(null)
|
||||
}
|
||||
|
||||
const unlinkBoard = async (board: Board): Promise<void> => {
|
||||
const newBoard = createBoard(board)
|
||||
newBoard.channelId = ''
|
||||
await mutator.updateBoard(newBoard, board, 'unlinked channel')
|
||||
for (const result of results) {
|
||||
if (result.id == board.id) {
|
||||
result.channelId = ''
|
||||
setResults([...results])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newLinkedBoard = async (): Promise<void> => {
|
||||
const board = createBoard()
|
||||
board.teamId = team.id
|
||||
board.channelId = currentChannel
|
||||
|
||||
const view = createBoardView()
|
||||
view.fields.viewType = 'board'
|
||||
view.parentId = board.id
|
||||
view.boardId = board.id
|
||||
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board view'})
|
||||
|
||||
await mutator.createBoardsAndBlocks(
|
||||
{boards: [board], blocks: [view]},
|
||||
'add linked board',
|
||||
async (bab: BoardsAndBlocks): Promise<void> => {
|
||||
const windowAny: any = window
|
||||
const newBoard = bab.boards[0]
|
||||
// TODO: Maybe create a new event for create linked board
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoard, {board: newBoard?.id})
|
||||
windowAny.WebappUtils.browserHistory.push(`/boards/team/${team.id}/${newBoard.id}`)
|
||||
dispatch(setLinkToChannel(''))
|
||||
},
|
||||
async () => {return},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='focalboard-body'>
|
||||
<Dialog
|
||||
className='BoardSelector'
|
||||
onClose={() => dispatch(setLinkToChannel(''))}
|
||||
>
|
||||
{showLinkBoardConfirmation &&
|
||||
<ConfirmationDialog
|
||||
dialogBox={{
|
||||
heading: intl.formatMessage({id: 'boardSelector.confirm-link-board', defaultMessage: 'Link board to channel'}),
|
||||
subText: intl.formatMessage({
|
||||
id: 'boardSelector.confirm-link-board-subtext',
|
||||
defaultMessage: 'Linking the "{boardName}" board to this channel would give all members of this channel "Editor" access to the board. Are you sure you want to link it?'
|
||||
}, {boardName: showLinkBoardConfirmation.title}),
|
||||
confirmButtonText: intl.formatMessage({id: 'boardSelector.confirm-link-board-button', defaultMessage: 'Yes, link board'}),
|
||||
onConfirm: () => linkBoard(showLinkBoardConfirmation, true),
|
||||
onClose: () => setShowLinkBoardConfirmation(null),
|
||||
}}
|
||||
/>}
|
||||
<div className='BoardSelectorBody'>
|
||||
<div className='head'>
|
||||
<div className='heading'>
|
||||
<h3 className='text-heading4'>
|
||||
<FormattedMessage
|
||||
id='boardSelector.title'
|
||||
defaultMessage='Link boards'
|
||||
/>
|
||||
</h3>
|
||||
<Button
|
||||
onClick={() => newLinkedBoard()}
|
||||
emphasis='secondary'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='boardSelector.create-a-board'
|
||||
defaultMessage='Create a board'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className='queryWrapper'>
|
||||
<SearchIcon/>
|
||||
<input
|
||||
className='searchQuery'
|
||||
placeholder={intl.formatMessage({id: 'boardSelector.search-for-boards', defaultMessage:'Search for boards'})}
|
||||
type='text'
|
||||
onChange={(e) => debouncedSearchHandler(e.target.value)}
|
||||
autoFocus={true}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='searchResults'>
|
||||
{/*When there are results to show*/}
|
||||
{searchQuery && results.length > 0 &&
|
||||
results.map((result) => (<BoardSelectorItem
|
||||
key={result.id}
|
||||
item={result}
|
||||
linkBoard={linkBoard}
|
||||
unlinkBoard={unlinkBoard}
|
||||
currentChannel={currentChannel}
|
||||
/>))}
|
||||
|
||||
{/*when user searched for something and there were no results*/}
|
||||
{emptyResult && <EmptyResults query={searchQuery}/>}
|
||||
|
||||
{/*default state, when user didn't search for anything. This is the initial screen*/}
|
||||
{!emptyResult && !searchQuery && <EmptySearch/>}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IntlBoardSelector = () => {
|
||||
const language = useAppSelector<string>(getLanguage)
|
||||
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={language.split(/[_]/)[0]}
|
||||
messages={getMessages(language)}
|
||||
>
|
||||
<BoardSelector/>
|
||||
</IntlProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default IntlBoardSelector
|
@ -0,0 +1,38 @@
|
||||
.BoardSelectorItem {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
padding: 10px 0;
|
||||
margin: 0 35px;
|
||||
|
||||
.icon {
|
||||
align-items: flex-start;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.resultLine {
|
||||
flex-grow: 1;
|
||||
width: 80%;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.resultTitle {
|
||||
overflow: hidden;
|
||||
max-width: 60%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resultDescription {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.linkUnlinkButton {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import {createBoard} from '../../../../webapp/src/blocks/board'
|
||||
import {wrapIntl} from '../../../../webapp/src/testUtils'
|
||||
|
||||
import BoardSelectorItem from './boardSelectorItem'
|
||||
|
||||
describe('components/boardSelectorItem', () => {
|
||||
it('renders board without title', async () => {
|
||||
const board = createBoard()
|
||||
board.title = ""
|
||||
|
||||
const {container} = render(wrapIntl(
|
||||
<BoardSelectorItem
|
||||
item={board}
|
||||
currentChannel={board.channelId || ''}
|
||||
linkBoard={jest.fn()}
|
||||
unlinkBoard={jest.fn()}
|
||||
/>,
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders linked board', async () => {
|
||||
const board = createBoard()
|
||||
board.title = "Test title"
|
||||
|
||||
const {container} = render(wrapIntl(
|
||||
<BoardSelectorItem
|
||||
item={board}
|
||||
currentChannel={board.channelId || ''}
|
||||
linkBoard={jest.fn()}
|
||||
unlinkBoard={jest.fn()}
|
||||
/>,
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders not linked board', async () => {
|
||||
const board = createBoard()
|
||||
board.title = "Test title"
|
||||
|
||||
const {container} = render(wrapIntl(
|
||||
<BoardSelectorItem
|
||||
item={board}
|
||||
currentChannel={'other-channel'}
|
||||
linkBoard={jest.fn()}
|
||||
unlinkBoard={jest.fn()}
|
||||
/>,
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('call handler on link', async () => {
|
||||
const board = createBoard()
|
||||
|
||||
const linkBoard = jest.fn()
|
||||
const unlinkBoard = jest.fn()
|
||||
|
||||
render(wrapIntl(
|
||||
<BoardSelectorItem
|
||||
item={board}
|
||||
currentChannel={'other-channel'}
|
||||
linkBoard={linkBoard}
|
||||
unlinkBoard={unlinkBoard}
|
||||
/>,
|
||||
))
|
||||
|
||||
const buttonElement = screen.getByRole('button')
|
||||
await userEvent.click(buttonElement)
|
||||
expect(linkBoard).toBeCalledWith(board)
|
||||
expect(unlinkBoard).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('call handler on unlink', async () => {
|
||||
const board = createBoard()
|
||||
|
||||
const linkBoard = jest.fn()
|
||||
const unlinkBoard = jest.fn()
|
||||
|
||||
render(wrapIntl(
|
||||
<BoardSelectorItem
|
||||
item={board}
|
||||
currentChannel={board.channelId || ''}
|
||||
linkBoard={linkBoard}
|
||||
unlinkBoard={unlinkBoard}
|
||||
/>,
|
||||
))
|
||||
|
||||
const buttonElement = screen.getByRole('button')
|
||||
await userEvent.click(buttonElement)
|
||||
expect(unlinkBoard).toBeCalledWith(board)
|
||||
expect(linkBoard).not.toBeCalled()
|
||||
})
|
||||
})
|
||||
|
@ -0,0 +1,56 @@
|
||||
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {useIntl, FormattedMessage} from 'react-intl'
|
||||
|
||||
import {Board} from '../../../../webapp/src/blocks/board'
|
||||
import Button from '../../../../webapp/src/widgets/buttons/button'
|
||||
|
||||
import './boardSelectorItem.scss'
|
||||
|
||||
type Props = {
|
||||
item: Board
|
||||
currentChannel: string
|
||||
linkBoard: (board: Board) => void
|
||||
unlinkBoard: (board: Board) => void
|
||||
}
|
||||
|
||||
const BoardSelectorItem = (props: Props) => {
|
||||
const {item, currentChannel} = props
|
||||
const intl = useIntl()
|
||||
const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'})
|
||||
const resultTitle = item.title || untitledBoardTitle
|
||||
return (
|
||||
<div className='BoardSelectorItem'>
|
||||
<span className='icon'>{item.icon}</span>
|
||||
<div className='resultLine'>
|
||||
<div className='resultTitle'>{resultTitle}</div>
|
||||
<div className='resultDescription'>{item.description}</div>
|
||||
</div>
|
||||
<div className='linkUnlinkButton'>
|
||||
{item.channelId === currentChannel &&
|
||||
<Button
|
||||
onClick={() => props.unlinkBoard(item)}
|
||||
emphasis='secondary'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='boardSelector.unlink'
|
||||
defaultMessage='Unlink'
|
||||
/>
|
||||
</Button>}
|
||||
{item.channelId !== currentChannel &&
|
||||
<Button
|
||||
onClick={() => props.linkBoard(item)}
|
||||
emphasis='primary'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='boardSelector.link'
|
||||
defaultMessage='Link'
|
||||
/>
|
||||
</Button>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default BoardSelectorItem
|
@ -0,0 +1,27 @@
|
||||
.RHSChannelBoardItem {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
border: 1px solid #cccccc;
|
||||
border-radius: 5px;
|
||||
margin-top: 10px;
|
||||
cursor: pointer;
|
||||
color: rgb(var(--center-channel-color-rgb));
|
||||
|
||||
.date {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.board-info {
|
||||
display: flex;
|
||||
font-size: 16px;
|
||||
|
||||
.icon {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {Provider as ReduxProvider} from 'react-redux'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import {createBoard} from '../../../../webapp/src/blocks/board'
|
||||
import {mockStateStore, wrapIntl} from '../../../../webapp/src/testUtils'
|
||||
|
||||
import RHSChannelBoardItem from './rhsChannelBoardItem'
|
||||
|
||||
describe('components/rhsChannelBoardItem', () => {
|
||||
it('render board', async () => {
|
||||
const state = {
|
||||
teams: {
|
||||
current: {
|
||||
id: 'team-id',
|
||||
name: 'team',
|
||||
display_name: 'Team name',
|
||||
},
|
||||
},
|
||||
}
|
||||
const board = createBoard()
|
||||
board.title = 'Test board'
|
||||
|
||||
const store = mockStateStore([], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoardItem board={board} />
|
||||
</ReduxProvider>
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('render board with menu open', async () => {
|
||||
const state = {
|
||||
teams: {
|
||||
current: {
|
||||
id: 'team-id',
|
||||
name: 'team',
|
||||
display_name: 'Team name',
|
||||
},
|
||||
},
|
||||
}
|
||||
const board = createBoard()
|
||||
board.title = 'Test board'
|
||||
|
||||
const store = mockStateStore([], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoardItem board={board} />
|
||||
</ReduxProvider>
|
||||
))
|
||||
|
||||
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
|
||||
await userEvent.click(buttonElement)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {FormattedMessage, useIntl} from 'react-intl'
|
||||
|
||||
import mutator from '../../../../webapp/src/mutator'
|
||||
import {Utils} from '../../../../webapp/src/utils'
|
||||
import {getCurrentTeam} from '../../../../webapp/src/store/teams'
|
||||
import {createBoard, Board} from '../../../../webapp/src/blocks/board'
|
||||
import {useAppSelector} from '../../../../webapp/src/store/hooks'
|
||||
import IconButton from '../../../../webapp/src/widgets/buttons/iconButton'
|
||||
import OptionsIcon from '../../../../webapp/src/widgets/icons/options'
|
||||
import DeleteIcon from '../../../../webapp/src/widgets/icons/delete'
|
||||
import Menu from '../../../../webapp/src/widgets/menu'
|
||||
import MenuWrapper from '../../../../webapp/src/widgets/menuWrapper'
|
||||
|
||||
import './rhsChannelBoardItem.scss'
|
||||
|
||||
type Props = {
|
||||
board: Board
|
||||
}
|
||||
|
||||
const RHSChannelBoardItem = (props: Props) => {
|
||||
const intl = useIntl()
|
||||
const board = props.board
|
||||
|
||||
const team = useAppSelector(getCurrentTeam)
|
||||
if (!team) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleBoardClicked = (boardID: string) => {
|
||||
const windowAny: any = window
|
||||
windowAny.WebappUtils.browserHistory.push(`/boards/team/${team.id}/${boardID}`)
|
||||
}
|
||||
|
||||
const onUnlinkBoard = async (board: Board) => {
|
||||
const newBoard = createBoard(board)
|
||||
newBoard.channelId = ''
|
||||
mutator.updateBoard(newBoard, board, 'unlinked channel')
|
||||
}
|
||||
|
||||
const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'})
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => handleBoardClicked(board.id)}
|
||||
className='RHSChannelBoardItem'
|
||||
>
|
||||
<div className='board-info'>
|
||||
{board.icon && <span className='icon'>{board.icon}</span>}
|
||||
<span className='title'>{board.title || untitledBoardTitle}</span>
|
||||
<MenuWrapper stopPropagationOnToggle={true}>
|
||||
<IconButton icon={<OptionsIcon/>}/>
|
||||
<Menu
|
||||
fixed={true}
|
||||
position='left'
|
||||
>
|
||||
<Menu.Text
|
||||
key={`unlinkBoard-${board.id}`}
|
||||
id='unlinkBoard'
|
||||
name={intl.formatMessage({id: 'rhs-boards.unlink-board', defaultMessage: 'Unlink board'})}
|
||||
icon={<DeleteIcon/>}
|
||||
onClick={() => {
|
||||
onUnlinkBoard(board)
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</div>
|
||||
<div>{board.description}</div>
|
||||
<div className='date'>
|
||||
<FormattedMessage
|
||||
id='rhs-boards.last-update-at'
|
||||
defaultMessage='Last update at: {datetime}'
|
||||
values={{datetime: Utils.displayDateTime(new Date(board.updateAt), intl as any)}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RHSChannelBoardItem
|
@ -0,0 +1,47 @@
|
||||
.RHSChannelBoards {
|
||||
padding: 20px;
|
||||
|
||||
&.empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 60px;
|
||||
}
|
||||
|
||||
.rhs-boards-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
>h2 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-paragraph {
|
||||
text-align: justify;
|
||||
text-align-last: center;
|
||||
}
|
||||
|
||||
.boards-screenshots {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.linked-boards {
|
||||
flex-grow: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rhs-boards-list {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.Button {
|
||||
width: auto;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {Provider as ReduxProvider} from 'react-redux'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
|
||||
import {createBoard} from '../../../../webapp/src/blocks/board'
|
||||
import {mockStateStore, wrapIntl} from '../../../../webapp/src/testUtils'
|
||||
|
||||
import RHSChannelBoards from './rhsChannelBoards'
|
||||
|
||||
describe('components/rhsChannelBoards', () => {
|
||||
const board1 = createBoard()
|
||||
const board2 = createBoard()
|
||||
const board3 = createBoard()
|
||||
board1.channelId = 'channel-id'
|
||||
board3.channelId = 'channel-id'
|
||||
|
||||
const team = {
|
||||
id: 'team-id',
|
||||
name: 'team',
|
||||
display_name: 'Team name',
|
||||
}
|
||||
const state = {
|
||||
teams: {
|
||||
allTeams: [team],
|
||||
current: team,
|
||||
},
|
||||
language: {
|
||||
value: 'en',
|
||||
},
|
||||
boards: {
|
||||
boards: {
|
||||
[board1.id]: board1,
|
||||
[board2.id]: board2,
|
||||
[board3.id]: board3,
|
||||
},
|
||||
myBoardMemberships: {
|
||||
[board1.id]: {boardId: board1.id, userId: 'user-id'},
|
||||
[board2.id]: {boardId: board2.id, userId: 'user-id'},
|
||||
[board3.id]: {boardId: board3.id, userId: 'user-id'},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
current: {
|
||||
id: 'channel-id',
|
||||
name: 'channel',
|
||||
display_name: 'Channel Name',
|
||||
type: 'O',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
it('renders the RHS for channel boards', async () => {
|
||||
const store = mockStateStore([], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoards/>
|
||||
</ReduxProvider>
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders with empty list of boards', async () => {
|
||||
const localState = {...state, boards: {...state.boards, boards: {}}}
|
||||
const store = mockStateStore([], localState)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoards/>
|
||||
</ReduxProvider>
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
119
mattermost-plugin/webapp/src/components/rhsChannelBoards.tsx
Normal file
119
mattermost-plugin/webapp/src/components/rhsChannelBoards.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {FormattedMessage, IntlProvider} from 'react-intl'
|
||||
|
||||
import {getMessages} from '../../../../webapp/src/i18n'
|
||||
import {getLanguage} from '../../../../webapp/src/store/language'
|
||||
|
||||
import {getCurrentTeam} from '../../../../webapp/src/store/teams'
|
||||
import {getCurrentChannel} from '../../../../webapp/src/store/channels'
|
||||
import {getMySortedBoards, setLinkToChannel} from '../../../../webapp/src/store/boards'
|
||||
import {useAppSelector, useAppDispatch} from '../../../../webapp/src/store/hooks'
|
||||
import AddIcon from '../../../../webapp/src/widgets/icons/add'
|
||||
import Button from '../../../../webapp/src/widgets/buttons/button'
|
||||
|
||||
import RHSChannelBoardItem from './rhsChannelBoardItem'
|
||||
|
||||
import './rhsChannelBoards.scss'
|
||||
|
||||
const boardsScreenshots = (window as any).baseURL + '/public/boards-screenshots.png'
|
||||
|
||||
const RHSChannelBoards = () => {
|
||||
const boards = useAppSelector(getMySortedBoards)
|
||||
const team = useAppSelector(getCurrentTeam)
|
||||
const currentChannel = useAppSelector(getCurrentChannel);
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
if (!boards) {
|
||||
return null
|
||||
}
|
||||
if (!team) {
|
||||
return null
|
||||
}
|
||||
if (!currentChannel) {
|
||||
return null
|
||||
}
|
||||
const channelBoards = boards.filter((b) => b.channelId === currentChannel.id)
|
||||
|
||||
if (channelBoards.length === 0) {
|
||||
return (
|
||||
<div className='focalboard-body'>
|
||||
<div className='RHSChannelBoards empty'>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id='rhs-boards.no-boards-linked-to-channel'
|
||||
defaultMessage='No boards are linked to {channelName} yet'
|
||||
values={{channelName: currentChannel.display_name}}
|
||||
/>
|
||||
</h2>
|
||||
<div className='empty-paragraph'>
|
||||
<FormattedMessage
|
||||
id='rhs-boards.no-boards-linked-to-channel-description'
|
||||
defaultMessage='Boards is a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view.'
|
||||
/>
|
||||
</div>
|
||||
<div className='boards-screenshots'><img src={boardsScreenshots}/></div>
|
||||
<Button
|
||||
onClick={() => dispatch(setLinkToChannel(currentChannel.id))}
|
||||
emphasis='primary'
|
||||
size='medium'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='rhs-boards.link-boards-to-channel'
|
||||
defaultMessage='Link boards to {channelName}'
|
||||
values={{channelName: currentChannel.display_name}}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='focalboard-body'>
|
||||
<div className='RHSChannelBoards'>
|
||||
<div className='rhs-boards-header'>
|
||||
<span className='linked-boards'>
|
||||
<FormattedMessage
|
||||
id='rhs-boards.linked-boards'
|
||||
defaultMessage='Linked boards'
|
||||
/>
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => dispatch(setLinkToChannel(currentChannel.id))}
|
||||
icon={<AddIcon/>}
|
||||
emphasis='primary'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='rhs-boards.add'
|
||||
defaultMessage='Add'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className='rhs-boards-list'>
|
||||
{channelBoards.map((b) => (
|
||||
<RHSChannelBoardItem
|
||||
key={b.id}
|
||||
board={b}
|
||||
/>))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IntlRHSChannelBoards = () => {
|
||||
const language = useAppSelector<string>(getLanguage)
|
||||
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={language.split(/[_]/)[0]}
|
||||
messages={getMessages(language)}
|
||||
>
|
||||
<RHSChannelBoards/>
|
||||
</IntlProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default IntlRHSChannelBoards
|
@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {Provider as ReduxProvider} from 'react-redux'
|
||||
import {render} from '@testing-library/react'
|
||||
|
||||
import {mockStateStore, wrapIntl} from '../../../../webapp/src/testUtils'
|
||||
|
||||
import RHSChannelBoardsHeader from './rhsChannelBoardsHeader'
|
||||
|
||||
describe('components/rhsChannelBoardsHeader', () => {
|
||||
it('renders the header', async () => {
|
||||
const state = {
|
||||
language: {
|
||||
value: 'en',
|
||||
},
|
||||
channels: {
|
||||
current: {
|
||||
id: 'channel-id',
|
||||
name: 'channel',
|
||||
display_name: 'Channel Name',
|
||||
type: 'O',
|
||||
},
|
||||
},
|
||||
}
|
||||
const store = mockStateStore([], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoardsHeader/>
|
||||
</ReduxProvider>
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {FormattedMessage, IntlProvider} from 'react-intl'
|
||||
|
||||
import {getMessages} from '../../../../webapp/src/i18n'
|
||||
import {getLanguage} from '../../../../webapp/src/store/language'
|
||||
import {getCurrentChannel} from '../../../../webapp/src/store/channels'
|
||||
import {useAppSelector} from '../../../../webapp/src/store/hooks'
|
||||
|
||||
const RHSChannelBoardsHeader = () => {
|
||||
const appBarIconURL = (window as any).baseURL + '/public/app-bar-icon.png'
|
||||
const currentChannel = useAppSelector(getCurrentChannel);
|
||||
const language = useAppSelector<string>(getLanguage)
|
||||
|
||||
if (!currentChannel) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={language.split(/[_]/)[0]}
|
||||
messages={getMessages(language)}
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
className='boards-rhs-header-logo'
|
||||
src={appBarIconURL}
|
||||
/>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='rhs-channel-boards-header.title'
|
||||
defaultMessage='Boards'
|
||||
/>
|
||||
</span>
|
||||
<span className='style--none sidebar--right__title__subtitle'>{currentChannel.display_name}</span>
|
||||
</div>
|
||||
</IntlProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default RHSChannelBoardsHeader
|
@ -21,6 +21,8 @@ windowAny.isFocalboardPlugin = true
|
||||
import App from '../../../webapp/src/app'
|
||||
import store from '../../../webapp/src/store'
|
||||
import {setTeam} from '../../../webapp/src/store/teams'
|
||||
import {setChannel} from '../../../webapp/src/store/channels'
|
||||
import {initialLoad} from '../../../webapp/src/store/initialLoad'
|
||||
import {Utils} from '../../../webapp/src/utils'
|
||||
import GlobalHeader from '../../../webapp/src/components/globalHeader/globalHeader'
|
||||
import FocalboardIcon from '../../../webapp/src/widgets/icons/logo'
|
||||
@ -34,6 +36,9 @@ import '../../../webapp/src/styles/labels.scss'
|
||||
import octoClient from '../../../webapp/src/octoClient'
|
||||
|
||||
import BoardsUnfurl from './components/boardsUnfurl/boardsUnfurl'
|
||||
import RHSChannelBoards from './components/rhsChannelBoards'
|
||||
import RHSChannelBoardsHeader from './components/rhsChannelBoardsHeader'
|
||||
import BoardSelector from './components/boardSelector'
|
||||
import wsClient, {
|
||||
MMWebSocketClient,
|
||||
ACTION_UPDATE_BLOCK,
|
||||
@ -163,6 +168,8 @@ const HeaderComponent = () => {
|
||||
|
||||
export default class Plugin {
|
||||
channelHeaderButtonId?: string
|
||||
rhsId?: string
|
||||
boardSelectorId?: string
|
||||
registry?: PluginRegistry
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function
|
||||
@ -179,12 +186,19 @@ export default class Plugin {
|
||||
setMattermostTheme(theme)
|
||||
let lastViewedChannel = mmStore.getState().entities.channels.currentChannelId
|
||||
let prevTeamID: string
|
||||
|
||||
const currentChannel = mmStore.getState().entities.channels.currentChannelId
|
||||
const currentChannelObj = mmStore.getState().entities.channels.channels[currentChannel]
|
||||
store.dispatch(setChannel(currentChannelObj))
|
||||
|
||||
mmStore.subscribe(() => {
|
||||
const currentUserId = mmStore.getState().entities.users.currentUserId
|
||||
const currentChannel = mmStore.getState().entities.channels.currentChannelId
|
||||
if (lastViewedChannel !== currentChannel && currentChannel) {
|
||||
localStorage.setItem('focalboardLastViewedChannel:' + currentUserId, currentChannel)
|
||||
lastViewedChannel = currentChannel
|
||||
const currentChannelObj = mmStore.getState().entities.channels.channels[lastViewedChannel]
|
||||
store.dispatch(setChannel(currentChannelObj))
|
||||
}
|
||||
|
||||
// Watch for change in active team.
|
||||
@ -192,7 +206,6 @@ export default class Plugin {
|
||||
const currentTeamID = mmStore.getState().entities.teams.currentTeamId
|
||||
if (currentTeamID && currentTeamID !== prevTeamID) {
|
||||
if (prevTeamID && window.location.pathname.startsWith(windowAny.frontendBaseURL || '')) {
|
||||
console.log("REDIRECTING HERE")
|
||||
browserHistory.push(`/team/${currentTeamID}`)
|
||||
wsClient.subscribeToTeam(currentTeamID)
|
||||
}
|
||||
@ -203,13 +216,24 @@ export default class Plugin {
|
||||
|
||||
if (this.registry.registerProduct) {
|
||||
windowAny.frontendBaseURL = subpath + '/boards'
|
||||
const goToFocalboard = () => {
|
||||
const currentTeam = mmStore.getState().entities.teams.currentTeamId
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ClickChannelHeader, {teamID: currentTeam})
|
||||
window.open(`${windowAny.frontendBaseURL}/team/${currentTeam}`, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
this.channelHeaderButtonId = registry.registerChannelHeaderButtonAction(<FocalboardIcon/>, goToFocalboard, 'Boards', 'Boards')
|
||||
const {rhsId, toggleRHSPlugin} = this.registry.registerRightHandSidebarComponent(
|
||||
() => (
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoards/>
|
||||
</ReduxProvider>
|
||||
),
|
||||
<ErrorBoundary>
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoardsHeader/>
|
||||
</ReduxProvider>
|
||||
</ErrorBoundary>
|
||||
,
|
||||
)
|
||||
this.rhsId = rhsId
|
||||
|
||||
this.channelHeaderButtonId = registry.registerChannelHeaderButtonAction(<FocalboardIcon/>, () => mmStore.dispatch(toggleRHSPlugin), 'Boards', 'Boards')
|
||||
|
||||
this.registry.registerProduct(
|
||||
'/boards',
|
||||
'product-boards',
|
||||
@ -233,12 +257,18 @@ export default class Plugin {
|
||||
|
||||
if (this.registry.registerAppBarComponent) {
|
||||
const appBarIconURL = windowAny.baseURL + '/public/app-bar-icon.png'
|
||||
this.registry.registerAppBarComponent(appBarIconURL, goToFocalboard, 'Open Boards')
|
||||
this.registry.registerAppBarComponent(appBarIconURL, () => mmStore.dispatch(toggleRHSPlugin), 'Boards')
|
||||
}
|
||||
|
||||
this.registry.registerPostWillRenderEmbedComponent((embed) => embed.type === 'boards', BoardsUnfurl, false)
|
||||
}
|
||||
|
||||
this.boardSelectorId = this.registry.registerRootComponent(() => (
|
||||
<ReduxProvider store={store}>
|
||||
<BoardSelector/>
|
||||
</ReduxProvider>
|
||||
))
|
||||
|
||||
const config = await octoClient.getClientConfig()
|
||||
if (config?.telemetry) {
|
||||
let rudderKey = TELEMETRY_RUDDER_KEY
|
||||
@ -309,12 +339,19 @@ export default class Plugin {
|
||||
// @ts-ignore
|
||||
return mmStore.getState().entities.teams.currentTeamId
|
||||
}
|
||||
store.dispatch(initialLoad())
|
||||
}
|
||||
|
||||
uninitialize(): void {
|
||||
if (this.channelHeaderButtonId) {
|
||||
this.registry?.unregisterComponent(this.channelHeaderButtonId)
|
||||
}
|
||||
if (this.rhsId) {
|
||||
this.registry?.unregisterComponent(this.rhsId)
|
||||
}
|
||||
if (this.boardSelectorId) {
|
||||
this.registry?.unregisterComponent(this.boardSelectorId)
|
||||
}
|
||||
|
||||
// unregister websocket handlers
|
||||
this.registry?.unregisterWebSocketEventHandler(wsClient.clientPrefix + ACTION_UPDATE_BLOCK)
|
||||
|
@ -19,3 +19,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img.boards-rhs-header-logo {
|
||||
color: white;
|
||||
background: var(--button-bg);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
@ -15,6 +15,8 @@ export interface PluginRegistry {
|
||||
registerWebSocketEventHandler(event: string, handler: (e: any) => void)
|
||||
unregisterWebSocketEventHandler(event: string)
|
||||
registerAppBarComponent(iconURL: string, action: (channel: Channel, member: ChannelMembership) => void, tooltipText: React.ReactNode)
|
||||
registerRightHandSidebarComponent(component: React.ElementType, title: React.Element)
|
||||
registerRootComponent(component: React.ElementType)
|
||||
|
||||
// Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference
|
||||
}
|
||||
|
1
mattermost-plugin/webapp/tests/style_mock.json
Normal file
1
mattermost-plugin/webapp/tests/style_mock.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
@ -61,6 +61,7 @@ module.exports = {
|
||||
],
|
||||
alias: {
|
||||
moment: path.resolve(__dirname, '../../webapp/node_modules/moment/'),
|
||||
'react-intl': path.resolve(__dirname, '../../webapp/node_modules/react-intl/'),
|
||||
},
|
||||
extensions: ['*', '.js', '.jsx', '.ts', '.tsx'],
|
||||
},
|
||||
@ -126,6 +127,7 @@ module.exports = {
|
||||
'mm-react-router-dom': 'ReactRouterDom',
|
||||
'prop-types': 'PropTypes',
|
||||
'react-bootstrap': 'ReactBootstrap',
|
||||
|
||||
},
|
||||
output: {
|
||||
devtoolNamespace: PLUGIN_ID,
|
||||
|
@ -92,6 +92,8 @@ func (a *API) RegisterRoutes(r *mux.Router) {
|
||||
}
|
||||
|
||||
// Board APIs
|
||||
apiv2.HandleFunc("/teams/{teamID}/channels", a.sessionRequired(a.handleSearchMyChannels)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/channels/{channelID}", a.sessionRequired(a.handleGetChannel)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/boards", a.sessionRequired(a.handleGetBoards)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/boards/search", a.sessionRequired(a.handleSearchBoards)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/templates", a.sessionRequired(a.handleGetTemplates)).Methods("GET")
|
||||
@ -2170,6 +2172,168 @@ func (a *API) handleGetTeamUsers(w http.ResponseWriter, r *http.Request) {
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleSearchMyChannels(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/channels searchMyChannels
|
||||
//
|
||||
// Returns the user available channels
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: search
|
||||
// in: query
|
||||
// description: string to filter channels list
|
||||
// required: false
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Channel"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
if !a.MattermostAuth {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusNotImplemented, "not permitted in standalone mode", nil)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
searchQuery := query.Get("search")
|
||||
|
||||
teamID := mux.Vars(r)["teamID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to team"})
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "searchMyChannels", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
|
||||
channels, err := a.app.SearchUserChannels(teamID, userID, searchQuery)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("GetUserChannels",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.Int("channelsCount", len(channels)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(channels)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("channelsCount", len(channels))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetChannel(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/channels/{channelID} getChannel
|
||||
//
|
||||
// Returns the requested channel
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: channelID
|
||||
// in: path
|
||||
// description: Channel ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Channel"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
if !a.MattermostAuth {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusNotImplemented, "not permitted in standalone mode", nil)
|
||||
return
|
||||
}
|
||||
|
||||
teamID := mux.Vars(r)["teamID"]
|
||||
channelID := mux.Vars(r)["channelID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to team"})
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToChannel(userID, channelID, model.PermissionReadChannel) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to channel"})
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getChannel", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
auditRec.AddMeta("channelID", teamID)
|
||||
|
||||
channel, err := a.app.GetChannel(teamID, channelID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("GetChannel",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.String("channelID", channelID),
|
||||
)
|
||||
|
||||
if channel.TeamId != teamID {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusNotFound, "", nil)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(channel)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/boards getBoards
|
||||
//
|
||||
@ -2829,6 +2993,12 @@ func (a *API) handlePatchBoard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if patch.ChannelID != nil {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardRoles) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to modifying board access"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "patchBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
|
@ -1,6 +1,9 @@
|
||||
package app
|
||||
|
||||
import "github.com/mattermost/focalboard/server/model"
|
||||
import (
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
func (a *App) GetTeamUsers(teamID string) ([]*model.User, error) {
|
||||
return a.store.GetUsersByTeam(teamID)
|
||||
@ -22,3 +25,11 @@ func (a *App) UpdateUserConfig(userID string, patch model.UserPropPatch) (map[st
|
||||
|
||||
return user.Props, nil
|
||||
}
|
||||
|
||||
func (a *App) SearchUserChannels(teamID string, userID string, query string) ([]*mmModel.Channel, error) {
|
||||
return a.store.SearchUserChannels(teamID, userID, query)
|
||||
}
|
||||
|
||||
func (a *App) GetChannel(teamID string, channelID string) (*mmModel.Channel, error) {
|
||||
return a.store.GetChannel(teamID, channelID)
|
||||
}
|
||||
|
@ -78,6 +78,10 @@ func (*FakePermissionPluginAPI) HasPermissionToTeam(userID string, teamID string
|
||||
return true
|
||||
}
|
||||
|
||||
func (*FakePermissionPluginAPI) HasPermissionToChannel(userID string, channelID string, permission *mmModel.Permission) bool {
|
||||
return channelID == "valid-channel-id"
|
||||
}
|
||||
|
||||
func getTestConfig() (*config.Configuration, error) {
|
||||
dbType, connectionString, err := sqlstore.PrepareNewTestDatabase()
|
||||
if err != nil {
|
||||
|
@ -665,6 +665,58 @@ func TestPermissionsPatchBoardMinimumRole(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionsPatchBoardChannelId(t *testing.T) {
|
||||
patch := toJSON(t, map[string]string{"channelId": "valid-channel-id"})
|
||||
ttCases := []TestCase{
|
||||
{"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userAnon, http.StatusUnauthorized, 0},
|
||||
{"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userNoTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userViewer, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userCommenter, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userEditor, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_BOARD_ID}", methodPatch, patch, userAdmin, http.StatusOK, 1},
|
||||
|
||||
{"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userAnon, http.StatusUnauthorized, 0},
|
||||
{"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userNoTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userViewer, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userCommenter, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userEditor, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_BOARD_ID}", methodPatch, patch, userAdmin, http.StatusOK, 1},
|
||||
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userAnon, http.StatusUnauthorized, 0},
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userNoTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userViewer, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userCommenter, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userEditor, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}", methodPatch, patch, userAdmin, http.StatusOK, 1},
|
||||
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userAnon, http.StatusUnauthorized, 0},
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userNoTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userViewer, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userCommenter, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userEditor, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}", methodPatch, patch, userAdmin, http.StatusOK, 1},
|
||||
}
|
||||
|
||||
t.Run("plugin", func(t *testing.T) {
|
||||
th := SetupTestHelperPluginMode(t)
|
||||
defer th.TearDown()
|
||||
clients := setupClients(th)
|
||||
testData := setupData(t, th)
|
||||
runTestCases(t, ttCases, testData, clients)
|
||||
})
|
||||
t.Run("local", func(t *testing.T) {
|
||||
th := SetupTestHelperLocalMode(t)
|
||||
defer th.TearDown()
|
||||
clients := setupLocalClients(th)
|
||||
testData := setupData(t, th)
|
||||
runTestCases(t, ttCases, testData, clients)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionsDeleteBoard(t *testing.T) {
|
||||
ttCases := []TestCase{
|
||||
{"/boards/{PRIVATE_BOARD_ID}", methodDelete, "", userAnon, http.StatusUnauthorized, 0},
|
||||
@ -3366,3 +3418,81 @@ func TestPermissionsMinimumRolesApplied(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionsChannels(t *testing.T) {
|
||||
t.Run("plugin", func(t *testing.T) {
|
||||
th := SetupTestHelperPluginMode(t)
|
||||
defer th.TearDown()
|
||||
clients := setupClients(th)
|
||||
testData := setupData(t, th)
|
||||
ttCases := []TestCase{
|
||||
{"/teams/test-team/channels", methodGet, "", userAnon, http.StatusUnauthorized, 0},
|
||||
{"/teams/test-team/channels", methodGet, "", userNoTeamMember, http.StatusForbidden, 0},
|
||||
{"/teams/test-team/channels", methodGet, "", userTeamMember, http.StatusOK, 2},
|
||||
{"/teams/test-team/channels", methodGet, "", userViewer, http.StatusOK, 2},
|
||||
{"/teams/test-team/channels", methodGet, "", userCommenter, http.StatusOK, 2},
|
||||
{"/teams/test-team/channels", methodGet, "", userEditor, http.StatusOK, 2},
|
||||
{"/teams/test-team/channels", methodGet, "", userAdmin, http.StatusOK, 2},
|
||||
}
|
||||
runTestCases(t, ttCases, testData, clients)
|
||||
})
|
||||
t.Run("local", func(t *testing.T) {
|
||||
th := SetupTestHelperLocalMode(t)
|
||||
defer th.TearDown()
|
||||
clients := setupLocalClients(th)
|
||||
testData := setupData(t, th)
|
||||
ttCases := []TestCase{
|
||||
{"/teams/test-team/channels", methodGet, "", userAnon, http.StatusUnauthorized, 0},
|
||||
{"/teams/test-team/channels", methodGet, "", userNoTeamMember, http.StatusNotImplemented, 0},
|
||||
{"/teams/test-team/channels", methodGet, "", userTeamMember, http.StatusNotImplemented, 0},
|
||||
{"/teams/test-team/channels", methodGet, "", userViewer, http.StatusNotImplemented, 0},
|
||||
{"/teams/test-team/channels", methodGet, "", userCommenter, http.StatusNotImplemented, 0},
|
||||
{"/teams/test-team/channels", methodGet, "", userEditor, http.StatusNotImplemented, 0},
|
||||
{"/teams/test-team/channels", methodGet, "", userAdmin, http.StatusNotImplemented, 0},
|
||||
}
|
||||
runTestCases(t, ttCases, testData, clients)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionsChannel(t *testing.T) {
|
||||
t.Run("plugin", func(t *testing.T) {
|
||||
th := SetupTestHelperPluginMode(t)
|
||||
defer th.TearDown()
|
||||
clients := setupClients(th)
|
||||
testData := setupData(t, th)
|
||||
ttCases := []TestCase{
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userAnon, http.StatusUnauthorized, 0},
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userNoTeamMember, http.StatusForbidden, 0},
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userTeamMember, http.StatusOK, 1},
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userViewer, http.StatusOK, 1},
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userCommenter, http.StatusOK, 1},
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userEditor, http.StatusOK, 1},
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userAdmin, http.StatusOK, 1},
|
||||
|
||||
{"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userAnon, http.StatusUnauthorized, 0},
|
||||
{"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userNoTeamMember, http.StatusForbidden, 0},
|
||||
{"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userTeamMember, http.StatusForbidden, 0},
|
||||
{"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userViewer, http.StatusForbidden, 0},
|
||||
{"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userCommenter, http.StatusForbidden, 0},
|
||||
{"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userEditor, http.StatusForbidden, 0},
|
||||
{"/teams/test-team/channels/not-valid-channel-id", methodGet, "", userAdmin, http.StatusForbidden, 0},
|
||||
}
|
||||
runTestCases(t, ttCases, testData, clients)
|
||||
})
|
||||
t.Run("local", func(t *testing.T) {
|
||||
th := SetupTestHelperLocalMode(t)
|
||||
defer th.TearDown()
|
||||
clients := setupLocalClients(th)
|
||||
testData := setupData(t, th)
|
||||
ttCases := []TestCase{
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userAnon, http.StatusUnauthorized, 0},
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userNoTeamMember, http.StatusNotImplemented, 0},
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userTeamMember, http.StatusNotImplemented, 0},
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userViewer, http.StatusNotImplemented, 0},
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userCommenter, http.StatusNotImplemented, 0},
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userEditor, http.StatusNotImplemented, 0},
|
||||
{"/teams/test-team/channels/valid-channel-id", methodGet, "", userAdmin, http.StatusNotImplemented, 0},
|
||||
}
|
||||
runTestCases(t, ttCases, testData, clients)
|
||||
})
|
||||
}
|
||||
|
@ -6,6 +6,8 @@ import (
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var errTestStore = errors.New("plugin test store error")
|
||||
@ -197,6 +199,42 @@ func (s *PluginTestStore) SearchUsersByTeam(teamID string, searchQuery string) (
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *PluginTestStore) SearchUserChannels(teamID, userID, query string) ([]*mmModel.Channel, error) {
|
||||
return []*mmModel.Channel{
|
||||
{
|
||||
TeamId: teamID,
|
||||
Id: "valid-channel-id",
|
||||
DisplayName: "Valid Channel",
|
||||
Name: "valid-channel",
|
||||
},
|
||||
{
|
||||
TeamId: teamID,
|
||||
Id: "valid-channel-id-2",
|
||||
DisplayName: "Valid Channel 2",
|
||||
Name: "valid-channel-2",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PluginTestStore) GetChannel(teamID, channel string) (*mmModel.Channel, error) {
|
||||
if channel == "valid-channel-id" {
|
||||
return &mmModel.Channel{
|
||||
TeamId: teamID,
|
||||
Id: "valid-channel-id",
|
||||
DisplayName: "Valid Channel",
|
||||
Name: "valid-channel",
|
||||
}, nil
|
||||
} else if channel == "valid-channel-id-2" {
|
||||
return &mmModel.Channel{
|
||||
TeamId: teamID,
|
||||
Id: "valid-channel-id-2",
|
||||
DisplayName: "Valid Channel 2",
|
||||
Name: "valid-channel-2",
|
||||
}, nil
|
||||
}
|
||||
return nil, errTestStore
|
||||
}
|
||||
|
||||
func (s *PluginTestStore) SearchBoardsForUser(term string, userID string) ([]*model.Board, error) {
|
||||
boards, err := s.Store.SearchBoardsForUser(term, userID)
|
||||
if err != nil {
|
||||
|
@ -125,6 +125,10 @@ type BoardPatch struct {
|
||||
// required: false
|
||||
ShowDescription *bool `json:"showDescription"`
|
||||
|
||||
// Indicates if the board shows the description on the interface
|
||||
// required: false
|
||||
ChannelID *string `json:"channelId"`
|
||||
|
||||
// The board updated properties
|
||||
// required: false
|
||||
UpdatedProperties map[string]interface{} `json:"updatedProperties"`
|
||||
@ -176,6 +180,10 @@ type BoardMember struct {
|
||||
// Marks the user as an viewer of the board
|
||||
// required: true
|
||||
SchemeViewer bool `json:"schemeViewer"`
|
||||
|
||||
// Marks the membership as generated by an access group
|
||||
// required: true
|
||||
Synthetic bool `json:"synthetic"`
|
||||
}
|
||||
|
||||
// BoardMetadata contains metadata for a Board
|
||||
@ -258,6 +266,10 @@ func (p *BoardPatch) Patch(board *Board) *Board {
|
||||
board.ShowDescription = *p.ShowDescription
|
||||
}
|
||||
|
||||
if p.ChannelID != nil {
|
||||
board.ChannelID = *p.ChannelID
|
||||
}
|
||||
|
||||
for key, property := range p.UpdatedProperties {
|
||||
board.Properties[key] = property
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
|
||||
var (
|
||||
PermissionViewTeam = mmModel.PermissionViewTeam
|
||||
PermissionReadChannel = mmModel.PermissionReadChannel
|
||||
PermissionViewMembers = mmModel.PermissionViewMembers
|
||||
PermissionCreatePublicChannel = mmModel.PermissionCreatePublicChannel
|
||||
PermissionCreatePrivateChannel = mmModel.PermissionCreatePrivateChannel
|
||||
|
@ -30,6 +30,13 @@ func (s *Service) HasPermissionToTeam(userID, teamID string, permission *mmModel
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) HasPermissionToChannel(userID, channelID string, permission *mmModel.Permission) bool {
|
||||
if userID == "" || channelID == "" || permission == nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) HasPermissionToBoard(userID, boardID string, permission *mmModel.Permission) bool {
|
||||
if userID == "" || boardID == "" || permission == nil {
|
||||
return false
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
|
||||
type APIInterface interface {
|
||||
HasPermissionToTeam(userID string, teamID string, permission *mmModel.Permission) bool
|
||||
HasPermissionToChannel(userID string, channelID string, permission *mmModel.Permission) bool
|
||||
LogError(string, ...interface{})
|
||||
}
|
||||
|
||||
@ -34,6 +35,13 @@ func (s *Service) HasPermissionToTeam(userID, teamID string, permission *mmModel
|
||||
return s.api.HasPermissionToTeam(userID, teamID, permission)
|
||||
}
|
||||
|
||||
func (s *Service) HasPermissionToChannel(userID, channelID string, permission *mmModel.Permission) bool {
|
||||
if userID == "" || channelID == "" || permission == nil {
|
||||
return false
|
||||
}
|
||||
return s.api.HasPermissionToChannel(userID, channelID, permission)
|
||||
}
|
||||
|
||||
func (s *Service) HasPermissionToBoard(userID, boardID string, permission *mmModel.Permission) bool {
|
||||
if userID == "" || boardID == "" || permission == nil {
|
||||
return false
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
|
||||
type PermissionsService interface {
|
||||
HasPermissionToTeam(userID, teamID string, permission *mmModel.Permission) bool
|
||||
HasPermissionToChannel(userID, channelID string, permission *mmModel.Permission) bool
|
||||
HasPermissionToBoard(userID, boardID string, permission *mmModel.Permission) bool
|
||||
}
|
||||
|
||||
|
@ -25,14 +25,6 @@ var systemsBot = &mmModel.Bot{
|
||||
DisplayName: "System",
|
||||
}
|
||||
|
||||
type NotSupportedError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (pe NotSupportedError) Error() string {
|
||||
return pe.msg
|
||||
}
|
||||
|
||||
// Store represents the abstraction of the data storage.
|
||||
type MattermostAuthLayer struct {
|
||||
store.Store
|
||||
@ -117,19 +109,19 @@ func (s *MattermostAuthLayer) GetUserByUsername(username string) (*model.User, e
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) CreateUser(user *model.User) error {
|
||||
return NotSupportedError{"no user creation allowed from focalboard, create it using mattermost"}
|
||||
return store.NewNotSupportedError("no user creation allowed from focalboard, create it using mattermost")
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) UpdateUser(user *model.User) error {
|
||||
return NotSupportedError{"no update allowed from focalboard, update it using mattermost"}
|
||||
return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost")
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) UpdateUserPassword(username, password string) error {
|
||||
return NotSupportedError{"no update allowed from focalboard, update it using mattermost"}
|
||||
return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost")
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) UpdateUserPasswordByID(userID, password string) error {
|
||||
return NotSupportedError{"no update allowed from focalboard, update it using mattermost"}
|
||||
return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost")
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) PatchUserProps(userID string, patch model.UserPropPatch) error {
|
||||
@ -178,27 +170,27 @@ func (s *MattermostAuthLayer) GetActiveUserCount(updatedSecondsAgo int64) (int,
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) GetSession(token string, expireTime int64) (*model.Session, error) {
|
||||
return nil, NotSupportedError{"sessions not used when using mattermost"}
|
||||
return nil, store.NewNotSupportedError("sessions not used when using mattermost")
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) CreateSession(session *model.Session) error {
|
||||
return NotSupportedError{"no update allowed from focalboard, update it using mattermost"}
|
||||
return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost")
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) RefreshSession(session *model.Session) error {
|
||||
return NotSupportedError{"no update allowed from focalboard, update it using mattermost"}
|
||||
return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost")
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) UpdateSession(session *model.Session) error {
|
||||
return NotSupportedError{"no update allowed from focalboard, update it using mattermost"}
|
||||
return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost")
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) DeleteSession(sessionID string) error {
|
||||
return NotSupportedError{"no update allowed from focalboard, update it using mattermost"}
|
||||
return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost")
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) CleanUpSessions(expireTime int64) error {
|
||||
return NotSupportedError{"no update allowed from focalboard, update it using mattermost"}
|
||||
return store.NewNotSupportedError("no update allowed from focalboard, update it using mattermost")
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) GetTeam(id string) (*model.Team, error) {
|
||||
@ -625,6 +617,183 @@ func (s *MattermostAuthLayer) GetCloudLimits() (*mmModel.ProductLimits, error) {
|
||||
return s.pluginAPI.GetCloudLimits()
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) implicitBoardMembershipsFromRows(rows *sql.Rows) ([]*model.BoardMember, error) {
|
||||
boardMembers := []*model.BoardMember{}
|
||||
|
||||
for rows.Next() {
|
||||
var boardMember model.BoardMember
|
||||
|
||||
err := rows.Scan(
|
||||
&boardMember.UserID,
|
||||
&boardMember.BoardID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boardMember.Roles = "editor"
|
||||
boardMember.SchemeEditor = true
|
||||
boardMember.Synthetic = true
|
||||
|
||||
boardMembers = append(boardMembers, &boardMember)
|
||||
}
|
||||
|
||||
return boardMembers, nil
|
||||
}
|
||||
func (s *MattermostAuthLayer) GetMemberForBoard(boardID, userID string) (*model.BoardMember, error) {
|
||||
bm, err := s.Store.GetMemberForBoard(boardID, userID)
|
||||
if model.IsErrNotFound(err) {
|
||||
b, err := s.Store.GetBoard(boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.ChannelID != "" {
|
||||
_, err := s.pluginAPI.GetChannelMember(b.ChannelID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.BoardMember{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
Roles: "editor",
|
||||
SchemeAdmin: false,
|
||||
SchemeEditor: true,
|
||||
SchemeCommenter: false,
|
||||
SchemeViewer: false,
|
||||
Synthetic: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return bm, nil
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) GetMembersForUser(userID string) ([]*model.BoardMember, error) {
|
||||
explicitMembers, err := s.Store.GetMembersForUser(userID)
|
||||
if err != nil {
|
||||
s.logger.Error(`getMembersForUser ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := s.getQueryBuilder().
|
||||
Select("Cm.userID, B.Id").
|
||||
From(s.tablePrefix + "boards AS B").
|
||||
Join("ChannelMembers AS CM ON B.channel_id=CM.channelId").
|
||||
Where(sq.Eq{"CM.userID": userID})
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`getMembersForUser ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
implicitMembers, err := s.implicitBoardMembershipsFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members := []*model.BoardMember{}
|
||||
existingMembers := map[string]bool{}
|
||||
for _, m := range explicitMembers {
|
||||
members = append(members, m)
|
||||
existingMembers[m.BoardID] = true
|
||||
}
|
||||
for _, m := range implicitMembers {
|
||||
if !existingMembers[m.BoardID] {
|
||||
members = append(members, m)
|
||||
}
|
||||
}
|
||||
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) GetMembersForBoard(boardID string) ([]*model.BoardMember, error) {
|
||||
explicitMembers, err := s.Store.GetMembersForBoard(boardID)
|
||||
if err != nil {
|
||||
s.logger.Error(`getMembersForBoard ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := s.getQueryBuilder().
|
||||
Select("Cm.userID, B.Id").
|
||||
From(s.tablePrefix + "boards AS B").
|
||||
Join("ChannelMembers AS CM ON B.channel_id=CM.channelId").
|
||||
Where(sq.Eq{"B.id": boardID}).
|
||||
Where(sq.NotEq{"B.channel_id": ""})
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`getMembersForBoard ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
implicitMembers, err := s.implicitBoardMembershipsFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members := []*model.BoardMember{}
|
||||
existingMembers := map[string]bool{}
|
||||
for _, m := range explicitMembers {
|
||||
members = append(members, m)
|
||||
existingMembers[m.UserID] = true
|
||||
}
|
||||
for _, m := range implicitMembers {
|
||||
if !existingMembers[m.UserID] {
|
||||
members = append(members, m)
|
||||
}
|
||||
}
|
||||
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) GetBoardsForUserAndTeam(userID, teamID string) ([]*model.Board, error) {
|
||||
members, err := s.GetMembersForUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boardIDs := []string{}
|
||||
for _, m := range members {
|
||||
boardIDs = append(boardIDs, m.BoardID)
|
||||
}
|
||||
|
||||
boards, err := s.Store.GetBoardsInTeamByIds(boardIDs, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return boards, nil
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) SearchUserChannels(teamID, userID, query string) ([]*mmModel.Channel, error) {
|
||||
channels, err := s.pluginAPI.GetChannelsForTeamForUser(teamID, userID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := []*mmModel.Channel{}
|
||||
count := 0
|
||||
for _, channel := range channels {
|
||||
if channel.Type != mmModel.ChannelTypeDirect &&
|
||||
channel.Type != mmModel.ChannelTypeGroup &&
|
||||
(strings.Contains(channel.Name, query) || strings.Contains(channel.DisplayName, query)) {
|
||||
result = append(result, channel)
|
||||
count++
|
||||
if count >= 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) GetChannel(teamID, channelID string) (*mmModel.Channel, error) {
|
||||
channel, err := s.pluginAPI.GetChannel(channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) getSystemBotID() (string, error) {
|
||||
botID, err := s.client.Bot.EnsureBot(systemsBot)
|
||||
if err != nil {
|
||||
|
@ -581,6 +581,21 @@ func (mr *MockStoreMockRecorder) GetBoardsForUserAndTeam(arg0, arg1 interface{})
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardsForUserAndTeam", reflect.TypeOf((*MockStore)(nil).GetBoardsForUserAndTeam), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetBoardsInTeamByIds mocks base method.
|
||||
func (m *MockStore) GetBoardsInTeamByIds(arg0 []string, arg1 string) ([]*model.Board, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetBoardsInTeamByIds", arg0, arg1)
|
||||
ret0, _ := ret[0].([]*model.Board)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetBoardsInTeamByIds indicates an expected call of GetBoardsInTeamByIds.
|
||||
func (mr *MockStoreMockRecorder) GetBoardsInTeamByIds(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardsInTeamByIds", reflect.TypeOf((*MockStore)(nil).GetBoardsInTeamByIds), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetCardLimitTimestamp mocks base method.
|
||||
func (m *MockStore) GetCardLimitTimestamp() (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@ -611,6 +626,21 @@ func (mr *MockStoreMockRecorder) GetCategory(arg0 interface{}) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCategory", reflect.TypeOf((*MockStore)(nil).GetCategory), arg0)
|
||||
}
|
||||
|
||||
// GetChannel mocks base method.
|
||||
func (m *MockStore) GetChannel(arg0, arg1 string) (*model0.Channel, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetChannel", arg0, arg1)
|
||||
ret0, _ := ret[0].(*model0.Channel)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetChannel indicates an expected call of GetChannel.
|
||||
func (mr *MockStoreMockRecorder) GetChannel(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannel", reflect.TypeOf((*MockStore)(nil).GetChannel), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetCloudLimits mocks base method.
|
||||
func (m *MockStore) GetCloudLimits() (*model0.ProductLimits, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@ -1248,6 +1278,21 @@ func (mr *MockStoreMockRecorder) SearchBoardsForUser(arg0, arg1 interface{}) *go
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchBoardsForUser", reflect.TypeOf((*MockStore)(nil).SearchBoardsForUser), arg0, arg1)
|
||||
}
|
||||
|
||||
// SearchUserChannels mocks base method.
|
||||
func (m *MockStore) SearchUserChannels(arg0, arg1, arg2 string) ([]*model0.Channel, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SearchUserChannels", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].([]*model0.Channel)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SearchUserChannels indicates an expected call of SearchUserChannels.
|
||||
func (mr *MockStoreMockRecorder) SearchUserChannels(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchUserChannels", reflect.TypeOf((*MockStore)(nil).SearchUserChannels), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// SearchUsersByTeam mocks base method.
|
||||
func (m *MockStore) SearchUsersByTeam(arg0, arg1 string) ([]*model.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -278,6 +278,24 @@ func (s *SQLStore) getBoardsForUserAndTeam(db sq.BaseRunner, userID, teamID stri
|
||||
return s.boardsFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardsInTeamByIds(db sq.BaseRunner, boardIDs []string, teamID string) ([]*model.Board, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardFields("b.")...).
|
||||
From(s.tablePrefix + "boards as b").
|
||||
Where(sq.Eq{"b.team_id": teamID}).
|
||||
Where(sq.Eq{"b.is_template": false}).
|
||||
Where(sq.Eq{"b.id": boardIDs})
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`getBoardsInTeamByIds ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
return s.boardsFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) insertBoard(db sq.BaseRunner, board *model.Board, userID string) (*model.Board, error) {
|
||||
// Generate tracking IDs for in-built templates
|
||||
if board.IsTemplate && board.TeamID == model.GlobalTeamID {
|
||||
@ -344,6 +362,7 @@ func (s *SQLStore) insertBoard(db sq.BaseRunner, board *model.Board, userID stri
|
||||
Where(sq.Eq{"id": board.ID}).
|
||||
Set("modified_by", userID).
|
||||
Set("type", board.Type).
|
||||
Set("channel_id", board.ChannelID).
|
||||
Set("minimum_role", board.MinimumRole).
|
||||
Set("title", board.Title).
|
||||
Set("description", board.Description).
|
||||
|
@ -88,6 +88,7 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}boards (
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
CREATE INDEX idx_board_team_id ON {{.prefix}}boards(team_id, is_template);
|
||||
CREATE INDEX idx_board_channel_id ON {{.prefix}}boards(channel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
@ -298,9 +299,10 @@ CREATE INDEX idx_boardmembers_user_id ON {{.prefix}}board_members(user_id);
|
||||
{{- /* if we're in plugin, migrate channel memberships to the board */ -}}
|
||||
{{if .plugin}}
|
||||
INSERT INTO {{.prefix}}board_members (
|
||||
SELECT B.Id, CM.UserId, CM.Roles, (CM.UserId=B.created_by) OR CM.SchemeAdmin, CM.SchemeUser, FALSE, CM.SchemeGuest
|
||||
SELECT B.Id, CM.UserId, CM.Roles, TRUE, TRUE, FALSE, FALSE
|
||||
FROM {{.prefix}}boards AS B
|
||||
INNER JOIN ChannelMembers as CM ON CM.ChannelId=B.channel_id
|
||||
WHERE CM.SchemeAdmin=True
|
||||
);
|
||||
{{end}}
|
||||
|
||||
|
@ -354,6 +354,11 @@ func (s *SQLStore) GetBoardsForUserAndTeam(userID string, teamID string) ([]*mod
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetBoardsInTeamByIds(boardIDs []string, teamID string) ([]*model.Board, error) {
|
||||
return s.getBoardsInTeamByIds(s.db, boardIDs, teamID)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetCardLimitTimestamp() (int64, error) {
|
||||
return s.getCardLimitTimestamp(s.db)
|
||||
|
||||
@ -364,6 +369,11 @@ func (s *SQLStore) GetCategory(id string) (*model.Category, error) {
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetChannel(teamID string, channelID string) (*mmModel.Channel, error) {
|
||||
return s.getChannel(s.db, teamID, channelID)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetCloudLimits() (*mmModel.ProductLimits, error) {
|
||||
return s.getCloudLimits(s.db)
|
||||
|
||||
@ -731,6 +741,11 @@ func (s *SQLStore) SearchBoardsForUser(term string, userID string) ([]*model.Boa
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SearchUserChannels(teamID string, userID string, query string) ([]*mmModel.Channel, error) {
|
||||
return s.searchUserChannels(s.db, teamID, userID, query)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SearchUsersByTeam(teamID string, searchQuery string) ([]*model.User, error) {
|
||||
return s.searchUsersByTeam(s.db, teamID, searchQuery)
|
||||
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
"github.com/mattermost/mattermost-plugin-api/cluster"
|
||||
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
@ -126,3 +127,11 @@ func (s *SQLStore) getLicense(db sq.BaseRunner) *mmModel.License {
|
||||
func (s *SQLStore) getCloudLimits(db sq.BaseRunner) (*mmModel.ProductLimits, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) searchUserChannels(db sq.BaseRunner, teamID, userID, query string) ([]*mmModel.Channel, error) {
|
||||
return nil, store.NewNotSupportedError("search user channels not supported on standalone mode")
|
||||
}
|
||||
|
||||
func (s *SQLStore) getChannel(db sq.BaseRunner, teamID, channel string) (*mmModel.Channel, error) {
|
||||
return nil, store.NewNotSupportedError("get channel not supported on standalone mode")
|
||||
}
|
||||
|
@ -90,6 +90,7 @@ type Store interface {
|
||||
PatchBoard(boardID string, boardPatch *model.BoardPatch, userID string) (*model.Board, error)
|
||||
GetBoard(id string) (*model.Board, error)
|
||||
GetBoardsForUserAndTeam(userID, teamID string) ([]*model.Board, error)
|
||||
GetBoardsInTeamByIds(boardIDs []string, teamID string) ([]*model.Board, error)
|
||||
// @withTransaction
|
||||
DeleteBoard(boardID, userID string) error
|
||||
|
||||
@ -150,6 +151,19 @@ type Store interface {
|
||||
|
||||
GetLicense() *mmModel.License
|
||||
GetCloudLimits() (*mmModel.ProductLimits, error)
|
||||
|
||||
SearchUserChannels(teamID, userID, query string) ([]*mmModel.Channel, error)
|
||||
GetChannel(teamID, channelID string) (*mmModel.Channel, error)
|
||||
SendMessage(message, postType string, receipts []string) error
|
||||
}
|
||||
|
||||
type NotSupportedError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func NewNotSupportedError(msg string) NotSupportedError {
|
||||
return NotSupportedError{msg: msg}
|
||||
}
|
||||
|
||||
func (pe NotSupportedError) Error() string {
|
||||
return pe.msg
|
||||
}
|
||||
|
@ -13,6 +13,7 @@
|
||||
"BoardMember.schemeNone": "None",
|
||||
"BoardMember.schemeViewer": "Viewer",
|
||||
"BoardMember.schemeViwer": "Viewer",
|
||||
"BoardMember.unlinkChannel": "Unlink",
|
||||
"BoardPage.newVersion": "A new version of Boards is available, click here to reload.",
|
||||
"BoardPage.syncFailed": "Board may be deleted or access revoked.",
|
||||
"BoardTemplateSelector.add-template": "New template",
|
||||
@ -138,7 +139,7 @@
|
||||
"Filter.not-includes": "doesn't include",
|
||||
"FilterComponent.add-filter": "+ Add filter",
|
||||
"FilterComponent.delete": "Delete",
|
||||
"FindBoFindBoardsDialog.IntroText": "Search for boards",
|
||||
"FindBoardsDialog.IntroText": "Search for boards",
|
||||
"FindBoardsDialog.NoResultsFor": "No results for \"{searchQuery}\"",
|
||||
"FindBoardsDialog.NoResultsSubtext": "Check the spelling or try another search.",
|
||||
"FindBoardsDialog.SubTitle": "Type to find a board. Use <b>UP/DOWN</b> to browse. <b>ENTER</b> to select, <b>ESC</b> to dismiss",
|
||||
@ -184,6 +185,7 @@
|
||||
"PropertyType.File": "File or media",
|
||||
"PropertyType.MultiSelect": "Multi select",
|
||||
"PropertyType.Number": "Number",
|
||||
"PropertyType.People": "People",
|
||||
"PropertyType.Person": "Person",
|
||||
"PropertyType.Phone": "Phone",
|
||||
"PropertyType.Select": "Select",
|
||||
@ -308,6 +310,14 @@
|
||||
"WelcomePage.Heading": "Welcome To Boards",
|
||||
"WelcomePage.NoThanks.Text": "No thanks, I'll figure it out myself",
|
||||
"Workspace.editing-board-template": "You're editing a board template.",
|
||||
"boardSelector.confirm-link-board": "Link board to channel",
|
||||
"boardSelector.confirm-link-board-button": "Yes, link board",
|
||||
"boardSelector.confirm-link-board-subtext": "Linking the \"{boardName}\" board to this channel would give all members of this channel \"Editor\" access to the board. Are you sure you want to link it?",
|
||||
"boardSelector.create-a-board": "Create a board",
|
||||
"boardSelector.link": "Link",
|
||||
"boardSelector.search-for-boards": "Search for boards",
|
||||
"boardSelector.title": "Link boards",
|
||||
"boardSelector.unlink": "Unlink",
|
||||
"calendar.month": "Month",
|
||||
"calendar.today": "TODAY",
|
||||
"calendar.week": "Week",
|
||||
@ -318,7 +328,7 @@
|
||||
"error.back-to-home": "Back to Home",
|
||||
"error.back-to-team": "Back to team",
|
||||
"error.board-not-found": "Board not found.",
|
||||
"error.go-login": "Login",
|
||||
"error.go-login": "Log in",
|
||||
"error.invalid-read-only-board": "You don’t have access to this board. Log in to access Boards.",
|
||||
"error.not-logged-in": "Your session may have expired or you're not logged in. Log in again to access Boards.",
|
||||
"error.page.title": "Sorry, something went wrong",
|
||||
@ -339,12 +349,25 @@
|
||||
"notification-box.card-limit-reached.text": "Card limit reached, to view older cards, {link}",
|
||||
"register.login-button": "or log in if you already have an account",
|
||||
"register.signup-title": "Sign up for your account",
|
||||
"rhs-boards.add": "Add",
|
||||
"rhs-boards.last-update-at": "Last update at: {datetime}",
|
||||
"rhs-boards.link-boards-to-channel": "Link boards to {channelName}",
|
||||
"rhs-boards.linked-boards": "Linked boards",
|
||||
"rhs-boards.no-boards-linked-to-channel": "No boards are linked to {channelName} yet",
|
||||
"rhs-boards.no-boards-linked-to-channel-description": "Boards is a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view.",
|
||||
"rhs-boards.unlink-board": "Unlink board",
|
||||
"rhs-channel-boards-header.title": "Boards",
|
||||
"share-board.publish": "Publish",
|
||||
"share-board.share": "Share",
|
||||
"shareBoard.channels-select-group": "Channels",
|
||||
"shareBoard.confirm-link-public-channel": "You're adding a public channel",
|
||||
"shareBoard.confirm-link-public-channel-button": "Yes, add public channel",
|
||||
"shareBoard.confirm-link-public-channel-subtext": "Anyone who joins that public channel will now get “Editor” access to the board, are you sure you want to proceed?",
|
||||
"shareBoard.lastAdmin": "Boards must have at least one Administrator",
|
||||
"shareBoard.members-select-group": "Members",
|
||||
"tutorial_tip.finish_tour": "Done",
|
||||
"tutorial_tip.got_it": "Got it",
|
||||
"tutorial_tip.ok": "Next",
|
||||
"tutorial_tip.out": "Opt out of these tips.",
|
||||
"tutorial_tip.seen": "Seen this before?"
|
||||
}
|
||||
}
|
||||
|
@ -59,6 +59,7 @@ type BoardMember = {
|
||||
schemeEditor: boolean
|
||||
schemeCommenter: boolean
|
||||
schemeViewer: boolean
|
||||
synthetic: boolean
|
||||
}
|
||||
|
||||
type BoardsAndBlocks = {
|
||||
|
@ -59,6 +59,10 @@
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
&.freesize {
|
||||
height: unset;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
}
|
||||
|
@ -19,6 +19,43 @@ type Props = {
|
||||
initialData?: Array<ReactNode>
|
||||
}
|
||||
|
||||
export const EmptySearch = () => (
|
||||
<div className='noResults introScreen'>
|
||||
<div className='iconWrapper'>
|
||||
<Search/>
|
||||
</div>
|
||||
<h4 className='text-heading4'>
|
||||
<FormattedMessage
|
||||
id='FindBoardsDialog.IntroText'
|
||||
defaultMessage='Search for boards'
|
||||
/>
|
||||
</h4>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const EmptyResults = (props: {query: string}) => (
|
||||
<div className='noResults'>
|
||||
<div className='iconWrapper'>
|
||||
<Search/>
|
||||
</div>
|
||||
<h4 className='text-heading4'>
|
||||
<FormattedMessage
|
||||
id='FindBoardsDialog.NoResultsFor'
|
||||
defaultMessage='No results for "{searchQuery}"'
|
||||
values={{
|
||||
searchQuery: props.query,
|
||||
}}
|
||||
/>
|
||||
</h4>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='FindBoardsDialog.NoResultsSubtext'
|
||||
defaultMessage='Check the spelling or try another search.'
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SearchDialog = (props: Props): JSX.Element => {
|
||||
const [results, setResults] = useState<Array<ReactNode>>(props.initialData || [])
|
||||
const [isSearching, setIsSearching] = useState<boolean>(false)
|
||||
@ -71,45 +108,10 @@ const SearchDialog = (props: Props): JSX.Element => {
|
||||
}
|
||||
|
||||
{/*when user searched for something and there were no results*/}
|
||||
{
|
||||
emptyResult &&
|
||||
<div className='noResults'>
|
||||
<div className='iconWrapper'>
|
||||
<Search/>
|
||||
</div>
|
||||
<h4 className='text-heading4'>
|
||||
<FormattedMessage
|
||||
id='FindBoardsDialog.NoResultsFor'
|
||||
defaultMessage='No results for "{searchQuery}"'
|
||||
values={{
|
||||
searchQuery,
|
||||
}}
|
||||
/>
|
||||
</h4>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='FindBoardsDialog.NoResultsSubtext'
|
||||
defaultMessage='Check the spelling or try another search.'
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
{emptyResult && <EmptyResults query={searchQuery}/>}
|
||||
|
||||
{/*default state, when user didn't search for anything. This is the initial screen*/}
|
||||
{
|
||||
!emptyResult && !searchQuery &&
|
||||
<div className='noResults introScreen'>
|
||||
<div className='iconWrapper'>
|
||||
<Search/>
|
||||
</div>
|
||||
<h4 className='text-heading4'>
|
||||
<FormattedMessage
|
||||
id='FindBoFindBoardsDialog.IntroText'
|
||||
defaultMessage='Search for boards'
|
||||
/>
|
||||
</h4>
|
||||
</div>
|
||||
}
|
||||
{!emptyResult && !searchQuery && <EmptySearch/>}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
@ -98,22 +98,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
|
||||
</div>
|
||||
<div
|
||||
class=" css-1hb7zxy-IndicatorsContainer"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class=" css-byrije-loadingIndicator"
|
||||
>
|
||||
<span
|
||||
class="css-1xtdfmb-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-zoievk-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-x748d8-LoadingDot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -322,22 +307,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
|
||||
</div>
|
||||
<div
|
||||
class=" css-1hb7zxy-IndicatorsContainer"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class=" css-byrije-loadingIndicator"
|
||||
>
|
||||
<span
|
||||
class="css-1xtdfmb-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-zoievk-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-x748d8-LoadingDot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -546,22 +516,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Regene
|
||||
</div>
|
||||
<div
|
||||
class=" css-1hb7zxy-IndicatorsContainer"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class=" css-byrije-loadingIndicator"
|
||||
>
|
||||
<span
|
||||
class="css-1xtdfmb-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-zoievk-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-x748d8-LoadingDot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -960,7 +915,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
|
||||
<span
|
||||
id="aria-context"
|
||||
>
|
||||
option username_1 focused, 1 of 4. 4 results 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.
|
||||
option username_1 focused, 0 of 2. 8 results 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
|
||||
@ -1012,106 +967,214 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
|
||||
class=" css-g29tl0-MenuList"
|
||||
>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-erqggd-option"
|
||||
id="react-select-10-option-0"
|
||||
tabindex="-1"
|
||||
class=" css-syji7d-Group"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
class=" css-18ng2q5-group"
|
||||
id="react-select-10-group-0-heading"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
Members
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="ml-3"
|
||||
aria-disabled="false"
|
||||
class=" css-erqggd-option"
|
||||
id="react-select-10-option-0-0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<strong>
|
||||
username_1
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
@username_1
|
||||
</strong>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
username_1
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
>
|
||||
@username_1
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-10-option-0-1"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
username_2
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
>
|
||||
@username_2
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-10-option-0-2"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
username_3
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
>
|
||||
@username_3
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-10-option-0-3"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
username_4
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
>
|
||||
@username_4
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-10-option-1"
|
||||
tabindex="-1"
|
||||
class=" css-syji7d-Group"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
class=" css-18ng2q5-group"
|
||||
id="react-select-10-group-1-heading"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
username_2
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
>
|
||||
@username_2
|
||||
</strong>
|
||||
</div>
|
||||
Channels
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-10-option-2"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
class="ml-3"
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-10-option-1-0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<strong>
|
||||
username_3
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
@username_3
|
||||
</strong>
|
||||
<i
|
||||
class="CompassIcon icon-lock-outline LockOutlineIcon"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Channel 1
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-10-option-3"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-10-option-1-1"
|
||||
tabindex="-1"
|
||||
>
|
||||
<strong>
|
||||
username_4
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
@username_4
|
||||
</strong>
|
||||
<i
|
||||
class="CompassIcon icon-lock-outline LockOutlineIcon"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Channel 2
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-10-option-1-2"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-globe GlobeIcon"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Channel 3
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-10-option-1-3"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-globe GlobeIcon"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Channel 4
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1480,7 +1543,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
|
||||
<span
|
||||
id="aria-context"
|
||||
>
|
||||
option username_1 focused, 1 of 4. 4 results 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.
|
||||
option username_1 focused, 0 of 2. 8 results 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
|
||||
@ -1532,106 +1595,214 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
|
||||
class=" css-g29tl0-MenuList"
|
||||
>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-erqggd-option"
|
||||
id="react-select-11-option-0"
|
||||
tabindex="-1"
|
||||
class=" css-syji7d-Group"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
class=" css-18ng2q5-group"
|
||||
id="react-select-11-group-0-heading"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
Members
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="ml-3"
|
||||
aria-disabled="false"
|
||||
class=" css-erqggd-option"
|
||||
id="react-select-11-option-0-0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<strong>
|
||||
username_1
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
@username_1
|
||||
</strong>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
username_1
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
>
|
||||
@username_1
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-11-option-0-1"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
username_2
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
>
|
||||
@username_2
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-11-option-0-2"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
username_3
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
>
|
||||
@username_3
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-11-option-0-3"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
username_4
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
>
|
||||
@username_4
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-11-option-1"
|
||||
tabindex="-1"
|
||||
class=" css-syji7d-Group"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
class=" css-18ng2q5-group"
|
||||
id="react-select-11-group-1-heading"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
username_2
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
>
|
||||
@username_2
|
||||
</strong>
|
||||
</div>
|
||||
Channels
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-11-option-2"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
class="ml-3"
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-11-option-1-0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<strong>
|
||||
username_3
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
@username_3
|
||||
</strong>
|
||||
<i
|
||||
class="CompassIcon icon-lock-outline LockOutlineIcon"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Channel 1
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-11-option-3"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<img
|
||||
class="user-item__img"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-11-option-1-1"
|
||||
tabindex="-1"
|
||||
>
|
||||
<strong>
|
||||
username_4
|
||||
</strong>
|
||||
<strong
|
||||
class="ml-2 text-light"
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
@username_4
|
||||
</strong>
|
||||
<i
|
||||
class="CompassIcon icon-lock-outline LockOutlineIcon"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Channel 2
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-11-option-1-2"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-globe GlobeIcon"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Channel 3
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class=" css-14xsrqy-option"
|
||||
id="react-select-11-option-1-3"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="user-item"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-globe GlobeIcon"
|
||||
/>
|
||||
<div
|
||||
class="ml-3"
|
||||
>
|
||||
<strong>
|
||||
Channel 4
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1833,22 +2004,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard, and click switc
|
||||
</div>
|
||||
<div
|
||||
class=" css-1hb7zxy-IndicatorsContainer"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class=" css-byrije-loadingIndicator"
|
||||
>
|
||||
<span
|
||||
class="css-1xtdfmb-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-zoievk-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-x748d8-LoadingDot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -2080,22 +2236,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoardComponent and cli
|
||||
</div>
|
||||
<div
|
||||
class=" css-1hb7zxy-IndicatorsContainer"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class=" css-byrije-loadingIndicator"
|
||||
>
|
||||
<span
|
||||
class="css-1xtdfmb-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-zoievk-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-x748d8-LoadingDot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -2327,22 +2468,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot 1`] = `
|
||||
</div>
|
||||
<div
|
||||
class=" css-1hb7zxy-IndicatorsContainer"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class=" css-byrije-loadingIndicator"
|
||||
>
|
||||
<span
|
||||
class="css-1xtdfmb-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-zoievk-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-x748d8-LoadingDot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -2551,22 +2677,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
|
||||
</div>
|
||||
<div
|
||||
class=" css-1hb7zxy-IndicatorsContainer"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class=" css-byrije-loadingIndicator"
|
||||
>
|
||||
<span
|
||||
class="css-1xtdfmb-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-zoievk-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-x748d8-LoadingDot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -2775,22 +2886,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
|
||||
</div>
|
||||
<div
|
||||
class=" css-1hb7zxy-IndicatorsContainer"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class=" css-byrije-loadingIndicator"
|
||||
>
|
||||
<span
|
||||
class="css-1xtdfmb-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-zoievk-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-x748d8-LoadingDot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -2999,22 +3095,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
|
||||
</div>
|
||||
<div
|
||||
class=" css-1hb7zxy-IndicatorsContainer"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class=" css-byrije-loadingIndicator"
|
||||
>
|
||||
<span
|
||||
class="css-1xtdfmb-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-zoievk-LoadingDot"
|
||||
/>
|
||||
<span
|
||||
class="css-x748d8-LoadingDot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
81
webapp/src/components/shareBoard/channelPermissionsRow.tsx
Normal file
81
webapp/src/components/shareBoard/channelPermissionsRow.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {useIntl, FormattedMessage} from 'react-intl'
|
||||
|
||||
import MenuWrapper from '../../widgets/menuWrapper'
|
||||
import Menu from '../../widgets/menu'
|
||||
|
||||
import {createBoard} from '../../blocks/board'
|
||||
import {useAppSelector} from '../../store/hooks'
|
||||
import {getCurrentBoard} from '../../store/boards'
|
||||
import {Channel} from '../../store/channels'
|
||||
import {Utils} from '../../utils'
|
||||
import mutator from '../../mutator'
|
||||
import octoClient from '../../octoClient'
|
||||
|
||||
import PrivateIcon from '../../widgets/icons/lockOutline'
|
||||
import PublicIcon from '../../widgets/icons/globe'
|
||||
import DeleteIcon from '../../widgets/icons/delete'
|
||||
import CompassIcon from '../../widgets/icons/compassIcon'
|
||||
|
||||
const ChannelPermissionsRow = (): JSX.Element => {
|
||||
const intl = useIntl()
|
||||
const board = useAppSelector(getCurrentBoard)
|
||||
const [linkedChannel, setLinkedChannel] = useState<Channel|null>(null)
|
||||
|
||||
const onUnlinkBoard = async () => {
|
||||
const newBoard = createBoard(board)
|
||||
newBoard.channelId = ''
|
||||
mutator.updateBoard(newBoard, board, 'unlinked channel')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Utils.isFocalboardPlugin() || !board.channelId) {
|
||||
setLinkedChannel(null)
|
||||
return
|
||||
}
|
||||
octoClient.getChannel(board.teamId, board.channelId).then((c) => setLinkedChannel(c || null))
|
||||
}, [board.channelId])
|
||||
|
||||
if (!linkedChannel) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='user-item'>
|
||||
<div className='user-item__content'>
|
||||
<span className='user-item__img'>
|
||||
{linkedChannel.type === 'P' && <PrivateIcon/>}
|
||||
{linkedChannel.type === 'O' && <PublicIcon/>}
|
||||
</span>
|
||||
<div className='ml-3'><strong>{linkedChannel.display_name}</strong></div>
|
||||
</div>
|
||||
<div>
|
||||
<MenuWrapper>
|
||||
<button className='user-item__button'>
|
||||
<FormattedMessage
|
||||
id='BoardMember.schemeEditor'
|
||||
defaultMessage='Editor'
|
||||
/>
|
||||
<CompassIcon
|
||||
icon='chevron-down'
|
||||
className='CompassIcon'
|
||||
/>
|
||||
</button>
|
||||
<Menu position='left'>
|
||||
<Menu.Text
|
||||
id='Unlink'
|
||||
icon={<DeleteIcon/>}
|
||||
name={intl.formatMessage({id: 'BoardMember.unlinkChannel', defaultMessage: 'Unlink'})}
|
||||
onClick={onUnlinkBoard}
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChannelPermissionsRow
|
@ -6,6 +6,13 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.confirmation-dialog-box {
|
||||
.dialog {
|
||||
position: fixed;
|
||||
width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding: 0;
|
||||
|
||||
|
@ -11,6 +11,7 @@ import {mocked} from 'jest-mock'
|
||||
|
||||
import {IUser} from '../../user'
|
||||
import {ISharing} from '../../blocks/sharing'
|
||||
import {Channel} from '../../store/channels'
|
||||
import {TestBlockFactory} from '../../test/testBlockFactory'
|
||||
import {mockStateStore, wrapDNDIntl} from '../../testUtils'
|
||||
import client from '../../octoClient'
|
||||
@ -485,8 +486,15 @@ describe('src/components/shareBoard/shareBoard', () => {
|
||||
{id: 'userid3', username: 'username_3'} as IUser,
|
||||
{id: 'userid4', username: 'username_4'} as IUser,
|
||||
]
|
||||
const channels:Channel[] = [
|
||||
{id: 'channel1', type: 'P', display_name: 'Channel 1'} as Channel,
|
||||
{id: 'channel2', type: 'P', display_name: 'Channel 2'} as Channel,
|
||||
{id: 'channel3', type: 'O', display_name: 'Channel 3'} as Channel,
|
||||
{id: 'channel4', type: 'O', display_name: 'Channel 4'} as Channel,
|
||||
]
|
||||
|
||||
mockedOctoClient.searchTeamUsers.mockResolvedValue(users)
|
||||
mockedOctoClient.searchUserChannels.mockResolvedValue(channels)
|
||||
|
||||
let container
|
||||
await act(async () => {
|
||||
@ -527,8 +535,15 @@ describe('src/components/shareBoard/shareBoard', () => {
|
||||
{id: 'userid3', username: 'username_3'} as IUser,
|
||||
{id: 'userid4', username: 'username_4'} as IUser,
|
||||
]
|
||||
const channels:Channel[] = [
|
||||
{id: 'channel1', type: 'P', display_name: 'Channel 1'} as Channel,
|
||||
{id: 'channel2', type: 'P', display_name: 'Channel 2'} as Channel,
|
||||
{id: 'channel3', type: 'O', display_name: 'Channel 3'} as Channel,
|
||||
{id: 'channel4', type: 'O', display_name: 'Channel 4'} as Channel,
|
||||
]
|
||||
|
||||
mockedOctoClient.searchTeamUsers.mockResolvedValue(users)
|
||||
mockedOctoClient.searchUserChannels.mockResolvedValue(channels)
|
||||
|
||||
let container
|
||||
await act(async () => {
|
||||
|
@ -10,6 +10,7 @@ import {CSSObject} from '@emotion/serialize'
|
||||
|
||||
import {useAppSelector} from '../../store/hooks'
|
||||
import {getCurrentBoard, getCurrentBoardMembers} from '../../store/boards'
|
||||
import {Channel, ChannelTypeOpen, ChannelTypePrivate} from '../../store/channels'
|
||||
import {getMe, getBoardUsersList} from '../../store/users'
|
||||
|
||||
import {Utils, IDType} from '../../utils'
|
||||
@ -17,10 +18,11 @@ import Tooltip from '../../widgets/tooltip'
|
||||
import mutator from '../../mutator'
|
||||
|
||||
import {ISharing} from '../../blocks/sharing'
|
||||
import {BoardMember} from '../../blocks/board'
|
||||
import {BoardMember, createBoard} from '../../blocks/board'
|
||||
|
||||
import client from '../../octoClient'
|
||||
import Dialog from '../dialog'
|
||||
import ConfirmationDialog from '../confirmationDialogBox'
|
||||
import {IUser} from '../../user'
|
||||
import Switch from '../../widgets/switch'
|
||||
import Button from '../../widgets/buttons/button'
|
||||
@ -33,12 +35,15 @@ import {getSelectBaseStyle} from '../../theme'
|
||||
import CompassIcon from '../../widgets/icons/compassIcon'
|
||||
import IconButton from '../../widgets/buttons/iconButton'
|
||||
import SearchIcon from '../../widgets/icons/search'
|
||||
import PrivateIcon from '../../widgets/icons/lockOutline'
|
||||
import PublicIcon from '../../widgets/icons/globe'
|
||||
|
||||
import BoardPermissionGate from '../permissions/boardPermissionGate'
|
||||
|
||||
import {useHasPermissions} from '../../hooks/permissions'
|
||||
|
||||
import TeamPermissionsRow from './teamPermissionsRow'
|
||||
import ChannelPermissionsRow from './channelPermissionsRow'
|
||||
import UserPermissionsRow from './userPermissionsRow'
|
||||
|
||||
import './shareBoard.scss'
|
||||
@ -92,8 +97,9 @@ function isLastAdmin(members: BoardMember[]) {
|
||||
export default function ShareBoardDialog(props: Props): JSX.Element {
|
||||
const [wasCopiedPublic, setWasCopiedPublic] = useState(false)
|
||||
const [wasCopiedInternal, setWasCopiedInternal] = useState(false)
|
||||
const [showLinkChannelConfirmation, setShowLinkChannelConfirmation] = useState<Channel|null>(null)
|
||||
const [sharing, setSharing] = useState<ISharing|undefined>(undefined)
|
||||
const [selectedUser, setSelectedUser] = useState<IUser|null>(null)
|
||||
const [selectedUser, setSelectedUser] = useState<IUser|Channel|null>(null)
|
||||
|
||||
// members of the current board
|
||||
const members = useAppSelector<{[key: string]: BoardMember}>(getCurrentBoardMembers)
|
||||
@ -135,6 +141,17 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
const onLinkBoard = async (channel: Channel, confirmed?: boolean) => {
|
||||
if (channel.type === ChannelTypeOpen && !confirmed) {
|
||||
setShowLinkChannelConfirmation(channel)
|
||||
return
|
||||
}
|
||||
setShowLinkChannelConfirmation(null)
|
||||
const newBoard = createBoard(board)
|
||||
newBoard.channelId = channel.id // This is a channel ID hardcoded here as an example
|
||||
mutator.updateBoard(newBoard, board, 'linked channel')
|
||||
}
|
||||
|
||||
const onRegenerateToken = async () => {
|
||||
// eslint-disable-next-line no-alert
|
||||
const accept = window.confirm(intl.formatMessage({id: 'ShareBoard.confirmRegenerateToken', defaultMessage: 'This will invalidate previously shared links. Continue?'}))
|
||||
@ -264,18 +281,36 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
|
||||
</span>
|
||||
)
|
||||
|
||||
const formatOptionLabel = (user: IUser) => {
|
||||
const formatOptionLabel = (userOrChannel: IUser | Channel) => {
|
||||
if ((userOrChannel as IUser).username) {
|
||||
const user = userOrChannel as IUser
|
||||
return(
|
||||
<div className='user-item'>
|
||||
{Utils.isFocalboardPlugin() &&
|
||||
<img
|
||||
src={Utils.getProfilePicture(user.id)}
|
||||
className='user-item__img'
|
||||
/>
|
||||
}
|
||||
<div className='ml-3'>
|
||||
<strong>{user.username}</strong>
|
||||
<strong className='ml-2 text-light'>{`@${user.username}`}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!Utils.isFocalboardPlugin()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const channel = userOrChannel as Channel
|
||||
return(
|
||||
<div className='user-item'>
|
||||
{Utils.isFocalboardPlugin() &&
|
||||
<img
|
||||
src={Utils.getProfilePicture(user.id)}
|
||||
className='user-item__img'
|
||||
/>
|
||||
}
|
||||
{channel.type === ChannelTypePrivate && <PrivateIcon/>}
|
||||
{channel.type === ChannelTypeOpen && <PublicIcon/>}
|
||||
<div className='ml-3'>
|
||||
<strong>{user.username}</strong>
|
||||
<strong className='ml-2 text-light'>{`@${user.username}`}</strong>
|
||||
<strong>{channel.display_name}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -289,6 +324,16 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
|
||||
className='ShareBoardDialog'
|
||||
toolbar={toolbar}
|
||||
>
|
||||
{showLinkChannelConfirmation &&
|
||||
<ConfirmationDialog
|
||||
dialogBox={{
|
||||
heading: intl.formatMessage({id: 'shareBoard.confirm-link-public-channel', defaultMessage: 'You\'re adding a public channel'}),
|
||||
subText: intl.formatMessage({id: 'shareBoard.confirm-link-public-channel-subtext', defaultMessage: 'Anyone who joins that public channel will now get “Editor” access to the board, are you sure you want to proceed?'}),
|
||||
confirmButtonText: intl.formatMessage({id: 'shareBoard.confirm-link-public-channel-button', defaultMessage: 'Yes, add public channel'}),
|
||||
onConfirm: () => onLinkBoard(showLinkChannelConfirmation, true),
|
||||
onClose: () => setShowLinkChannelConfirmation(null),
|
||||
}}
|
||||
/>}
|
||||
<BoardPermissionGate permissions={[Permission.ManageBoardRoles]}>
|
||||
<div className='share-input__container'>
|
||||
<div className='share-input'>
|
||||
@ -298,18 +343,31 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
|
||||
value={selectedUser}
|
||||
className={'userSearchInput'}
|
||||
cacheOptions={true}
|
||||
loadOptions={(inputValue: string) => client.searchTeamUsers(inputValue)}
|
||||
loadOptions={async (inputValue: string) => {
|
||||
const users = await client.searchTeamUsers(inputValue)
|
||||
const channels = await client.searchUserChannels(match.params.teamId || '', inputValue)
|
||||
const result = []
|
||||
if (users) {
|
||||
result.push({label: intl.formatMessage({id: 'shareBoard.members-select-group', defaultMessage: 'Members'}), options: users || []})
|
||||
}
|
||||
if (channels) {
|
||||
result.push({label: intl.formatMessage({id: 'shareBoard.channels-select-group', defaultMessage: 'Channels'}), options: channels || []})
|
||||
}
|
||||
return result
|
||||
}}
|
||||
components={{DropdownIndicator: () => null, IndicatorSeparator: () => null}}
|
||||
defaultOptions={true}
|
||||
formatOptionLabel={formatOptionLabel}
|
||||
getOptionValue={(u) => u.id}
|
||||
getOptionLabel={(u) => u.username}
|
||||
getOptionLabel={(u: IUser|Channel) => (u as IUser).username || (u as Channel).display_name}
|
||||
isMulti={false}
|
||||
placeholder={intl.formatMessage({id: 'ShareBoard.searchPlaceholder', defaultMessage: 'Search for people'})}
|
||||
onChange={(newValue) => {
|
||||
if (newValue) {
|
||||
if (newValue && (newValue as IUser).username) {
|
||||
mutator.createBoardMember(boardId, newValue.id)
|
||||
setSelectedUser(null)
|
||||
} else if (newValue) {
|
||||
onLinkBoard(newValue as Channel)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -318,11 +376,15 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
|
||||
</BoardPermissionGate>
|
||||
<div className='user-items'>
|
||||
<TeamPermissionsRow/>
|
||||
<ChannelPermissionsRow/>
|
||||
|
||||
{boardUsers.map((user) => {
|
||||
if (!members[user.id]) {
|
||||
return null
|
||||
}
|
||||
if (members[user.id].synthetic) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<UserPermissionsRow
|
||||
key={user.id}
|
||||
|
@ -83,7 +83,7 @@ function errorDefFromId(id: ErrorId | null): ErrorDef {
|
||||
case ErrorId.InvalidReadOnlyBoard: {
|
||||
errDef.title = intl.formatMessage({id: 'error.invalid-read-only-board', defaultMessage: 'You don\’t have access to this board. Log in to access Boards.'})
|
||||
errDef.button1Enabled = true
|
||||
errDef.button1Text = intl.formatMessage({id: 'error.go-login', defaultMessage: 'Login'})
|
||||
errDef.button1Text = intl.formatMessage({id: 'error.go-login', defaultMessage: 'Log in'})
|
||||
errDef.button1Redirect = (): string => {
|
||||
return window.location.origin
|
||||
}
|
||||
|
@ -1,18 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {useEffect} from 'react'
|
||||
|
||||
import {Block} from '../blocks/block'
|
||||
import wsClient, {WSClient} from '../wsclient'
|
||||
|
||||
export default function useCardListener(onChange: (blocks: Block[]) => void, onReconnect: () => void): void {
|
||||
useEffect(() => {
|
||||
const onChangeHandler = (_: WSClient, blocks: Block[]) => onChange(blocks)
|
||||
wsClient.addOnChange(onChangeHandler, 'block')
|
||||
wsClient.addOnReconnect(onReconnect)
|
||||
return () => {
|
||||
wsClient.removeOnChange(onChangeHandler, 'block')
|
||||
wsClient.removeOnReconnect(onReconnect)
|
||||
}
|
||||
}, [])
|
||||
}
|
@ -9,6 +9,7 @@ import {Utils} from './utils'
|
||||
import {ClientConfig} from './config/clientConfig'
|
||||
import {UserSettings} from './userSettings'
|
||||
import {Category, CategoryBoards} from './store/sidebar'
|
||||
import {Channel} from './store/channels'
|
||||
import {Team} from './store/teams'
|
||||
import {Subscription} from './wsclient'
|
||||
import {PrepareOnboardingResponse} from './onboardingTour'
|
||||
@ -795,6 +796,32 @@ class OctoClient {
|
||||
return (await this.getJson(response, [])) as Subscription[]
|
||||
}
|
||||
|
||||
async searchUserChannels(teamId: string, searchQuery: string): Promise<Channel[] | undefined> {
|
||||
const path = `/api/v2/teams/${teamId}/channels?search=${searchQuery}`
|
||||
const response = await fetch(this.getBaseURL() + path, {
|
||||
headers: this.headers(),
|
||||
method: 'GET',
|
||||
})
|
||||
if (response.status !== 200) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (await this.getJson(response, [])) as Channel[]
|
||||
}
|
||||
|
||||
async getChannel(teamId: string, channelId: string): Promise<Channel | undefined> {
|
||||
const path = `/api/v2/teams/${teamId}/channels/${channelId}`
|
||||
const response = await fetch(this.getBaseURL() + path, {
|
||||
headers: this.headers(),
|
||||
method: 'GET',
|
||||
})
|
||||
if (response.status !== 200) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (await this.getJson(response, {})) as Channel
|
||||
}
|
||||
|
||||
// onboarding
|
||||
async prepareOnboarding(teamId: string): Promise<PrepareOnboardingResponse | undefined> {
|
||||
const path = `/api/v2/teams/${teamId}/onboard`
|
||||
|
@ -16,6 +16,7 @@ import {RootState} from './index'
|
||||
type BoardsState = {
|
||||
current: string
|
||||
loadingBoard: boolean,
|
||||
linkToChannel: string,
|
||||
boards: {[key: string]: Board}
|
||||
templates: {[key: string]: Board}
|
||||
membersInBoards: {[key: string]: {[key: string]: BoardMember}}
|
||||
@ -119,11 +120,14 @@ export const updateMembers = (state: BoardsState, action: PayloadAction<BoardMem
|
||||
|
||||
const boardsSlice = createSlice({
|
||||
name: 'boards',
|
||||
initialState: {loadingBoard: false, boards: {}, templates: {}, membersInBoards: {}, myBoardMemberships: {}} as BoardsState,
|
||||
initialState: {loadingBoard: false, linkToChannel: '', boards: {}, templates: {}, membersInBoards: {}, myBoardMemberships: {}} as BoardsState,
|
||||
reducers: {
|
||||
setCurrent: (state, action: PayloadAction<string>) => {
|
||||
state.current = action.payload
|
||||
},
|
||||
setLinkToChannel: (state, action: PayloadAction<string>) => {
|
||||
state.linkToChannel = action.payload
|
||||
},
|
||||
updateBoards: (state, action: PayloadAction<Board[]>) => {
|
||||
for (const board of action.payload) {
|
||||
if (board.deleteAt !== 0) {
|
||||
@ -192,14 +196,14 @@ const boardsSlice = createSlice({
|
||||
},
|
||||
})
|
||||
|
||||
export const {updateBoards, setCurrent} = boardsSlice.actions
|
||||
export const {updateBoards, setCurrent, setLinkToChannel} = boardsSlice.actions
|
||||
export const {reducer} = boardsSlice
|
||||
|
||||
export const getBoards = (state: RootState): {[key: string]: Board} => state.boards.boards
|
||||
export const getBoards = (state: RootState): {[key: string]: Board} => state.boards?.boards || {}
|
||||
|
||||
export const getMySortedBoards = createSelector(
|
||||
getBoards,
|
||||
(state: RootState): {[key: string]: BoardMember} => state.boards.myBoardMemberships,
|
||||
(state: RootState): {[key: string]: BoardMember} => state.boards?.myBoardMemberships || {},
|
||||
(boards, myBoardMemberships: {[key: string]: BoardMember}) => {
|
||||
return Object.values(boards).filter((b) => myBoardMemberships[b.id])
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
@ -247,3 +251,5 @@ export function getMyBoardMembership(boardId: string): (state: RootState) => Boa
|
||||
return state.boards.myBoardMemberships[boardId] || null
|
||||
}
|
||||
}
|
||||
|
||||
export const getCurrentLinkToChannel = (state: RootState): string => state.boards.linkToChannel
|
||||
|
46
webapp/src/store/channels.ts
Normal file
46
webapp/src/store/channels.ts
Normal file
@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {createSlice, PayloadAction} from '@reduxjs/toolkit'
|
||||
|
||||
import {RootState} from './index'
|
||||
|
||||
export const ChannelTypeOpen = 'O'
|
||||
export const ChannelTypePrivate = 'P'
|
||||
export const ChannelTypeDirectMessage = 'D'
|
||||
export const ChannelTypeGroupMessage = 'G'
|
||||
const channelTypes = [ChannelTypeOpen, ChannelTypePrivate, ChannelTypeDirectMessage, ChannelTypeGroupMessage]
|
||||
type ChannelType = typeof channelTypes[number]
|
||||
|
||||
export interface Channel {
|
||||
id: string
|
||||
name: string
|
||||
display_name: string
|
||||
type: ChannelType
|
||||
}
|
||||
|
||||
type ChannelState = {
|
||||
current: Channel | null
|
||||
}
|
||||
|
||||
const channelSlice = createSlice({
|
||||
name: 'channels',
|
||||
initialState: {
|
||||
current: null,
|
||||
} as ChannelState,
|
||||
reducers: {
|
||||
setChannel: (state, action: PayloadAction<Channel>) => {
|
||||
const channel = action.payload
|
||||
if (state.current === channel) {
|
||||
return
|
||||
}
|
||||
|
||||
state.current = channel
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const {setChannel} = channelSlice.actions
|
||||
export const {reducer} = channelSlice
|
||||
|
||||
export const getCurrentChannel = (state: RootState): Channel|null => state.channels.current
|
@ -4,9 +4,8 @@
|
||||
import {configureStore} from '@reduxjs/toolkit'
|
||||
|
||||
import {reducer as usersReducer} from './users'
|
||||
|
||||
// import {reducer as workspaceReducer} from './workspace'
|
||||
import {reducer as teamReducer} from './teams'
|
||||
import {reducer as teamsReducer} from './teams'
|
||||
import {reducer as channelsReducer} from './channels'
|
||||
import {reducer as languageReducer} from './language'
|
||||
import {reducer as globalTemplatesReducer} from './globalTemplates'
|
||||
import {reducer as boardsReducer} from './boards'
|
||||
@ -23,9 +22,8 @@ import {reducer as limitsReducer} from './limits'
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
users: usersReducer,
|
||||
|
||||
// workspace: workspaceReducer,
|
||||
teams: teamReducer,
|
||||
teams: teamsReducer,
|
||||
channels: channelsReducer,
|
||||
language: languageReducer,
|
||||
globalTemplates: globalTemplatesReducer,
|
||||
boards: boardsReducer,
|
||||
|
@ -29,11 +29,11 @@ export type ElementType = HTMLInputElement | HTMLTextAreaElement
|
||||
export type ElementProps = {
|
||||
className: string,
|
||||
placeholder?: string,
|
||||
onChange: (e: React.ChangeEvent<ElementType>) => void,
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement|HTMLInputElement>) => void,
|
||||
value?: string,
|
||||
title?: string,
|
||||
onBlur: () => void,
|
||||
onKeyDown: (e: React.KeyboardEvent<ElementType>) => void,
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement|HTMLInputElement>) => void,
|
||||
readOnly?: boolean,
|
||||
spellCheck?: boolean,
|
||||
onFocus?: () => void,
|
||||
@ -95,7 +95,7 @@ export function useEditable(
|
||||
value,
|
||||
title: value,
|
||||
onBlur: () => save('onBlur'),
|
||||
onKeyDown: (e: React.KeyboardEvent<ElementType>): void => {
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement|HTMLInputElement>): void => {
|
||||
if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC
|
||||
e.preventDefault()
|
||||
if (props.saveOnEsc) {
|
||||
|
Loading…
Reference in New Issue
Block a user