1
0
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:
Jesús Espino 2022-07-07 16:46:53 +02:00 committed by GitHub
parent 685d74a817
commit 46fdbf9048
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 3450 additions and 421 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

View File

@ -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",

View File

@ -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": [
"",

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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>
`;

View 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);
}
}
}
}
}

View File

@ -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()
})
})

View 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

View File

@ -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;
}
}

View File

@ -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()
})
})

View File

@ -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

View File

@ -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;
}
}
}

View File

@ -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()
})
})

View File

@ -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

View File

@ -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;
}
}

View File

@ -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()
})
})

View 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

View File

@ -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()
})
})

View File

@ -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

View File

@ -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)

View File

@ -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;
}

View File

@ -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
}

View File

@ -0,0 +1 @@
{}

View File

@ -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,

View File

@ -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)

View File

@ -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)
}

View File

@ -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 {

View File

@ -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)
})
}

View File

@ -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 {

View File

@ -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
}

View File

@ -6,6 +6,7 @@ import (
var (
PermissionViewTeam = mmModel.PermissionViewTeam
PermissionReadChannel = mmModel.PermissionReadChannel
PermissionViewMembers = mmModel.PermissionViewMembers
PermissionCreatePublicChannel = mmModel.PermissionCreatePublicChannel
PermissionCreatePrivateChannel = mmModel.PermissionCreatePrivateChannel

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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 {

View File

@ -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()

View File

@ -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).

View File

@ -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}}

View File

@ -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)

View File

@ -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")
}

View File

@ -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
}

View File

@ -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?"
}
}

View File

@ -59,6 +59,7 @@ type BoardMember = {
schemeEditor: boolean
schemeCommenter: boolean
schemeViewer: boolean
synthetic: boolean
}
type BoardsAndBlocks = {

View File

@ -59,6 +59,10 @@
cursor: pointer;
overflow: hidden;
&.freesize {
height: unset;
}
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.08);
}

View File

@ -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>

View File

@ -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>

View 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

View File

@ -6,6 +6,13 @@
position: relative;
}
.confirmation-dialog-box {
.dialog {
position: fixed;
width: 500px;
}
}
.toolbar {
padding: 0;

View File

@ -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 () => {

View File

@ -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}

View File

@ -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
}

View File

@ -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)
}
}, [])
}

View File

@ -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`

View File

@ -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

View 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

View File

@ -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,

View File

@ -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) {