mirror of
https://github.com/mattermost/focalboard.git
synced 2025-03-20 20:45:00 +02:00
Refactor websockets state and lifecycle (#3315)
* Refactor websockets state and lifecycle This PR moves the state of the authentication and subscriptions to the websockets client, allowing for multiple components to communicate with it and request subscriptions independently. With this change, the lifecycle of the websockets client is now managed on a component, and a hook is provided for easy access to it from individual components. * Fix linter * Integrating the new websockets in channels integration with the RHS and board selector * Some small fixes around boards-channels relationship * Make the boards unfurl to always use the current team * Fixing weird behaviors in websockets and other small data related bugs in channel-board relationship * Only warn if withWebSockets is used without a base connection * Fix tests * Fix linter * Update snapshot * Fixing plugin tests Co-authored-by: Jesús Espino <jespinog@gmail.com>
This commit is contained in:
parent
4561d2c787
commit
3ae821d2e8
mattermost-plugin/webapp/src
components
index.tsxserver
api
app
integrationtests
services/store
webapp/src
@ -34,6 +34,7 @@ describe('components/boardSelector', () => {
|
||||
teams: {
|
||||
allTeams: [team],
|
||||
current: team,
|
||||
currentId: team.id,
|
||||
},
|
||||
language: {
|
||||
value: 'en',
|
||||
|
@ -7,9 +7,11 @@ import debounce from 'lodash/debounce'
|
||||
import {getMessages} from '../../../../webapp/src/i18n'
|
||||
import {getLanguage} from '../../../../webapp/src/store/language'
|
||||
|
||||
import {useWebsockets} from '../../../../webapp/src/hooks/websockets'
|
||||
|
||||
import octoClient from '../../../../webapp/src/octoClient'
|
||||
import mutator from '../../../../webapp/src/mutator'
|
||||
import {getCurrentTeam, getAllTeams, Team} from '../../../../webapp/src/store/teams'
|
||||
import {getCurrentTeamId, 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'
|
||||
@ -20,6 +22,7 @@ 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 {WSClient} from '../../../../webapp/src/wsclient'
|
||||
|
||||
import BoardSelectorItem from './boardSelectorItem'
|
||||
|
||||
@ -31,7 +34,7 @@ const BoardSelector = () => {
|
||||
teamsById[t.id] = t
|
||||
})
|
||||
const intl = useIntl()
|
||||
const team = useAppSelector(getCurrentTeam)
|
||||
const teamId = useAppSelector(getCurrentTeamId)
|
||||
const currentChannel = useAppSelector(getCurrentLinkToChannel)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
@ -43,20 +46,45 @@ const BoardSelector = () => {
|
||||
const searchHandler = useCallback(async (query: string): Promise<void> => {
|
||||
setSearchQuery(query)
|
||||
|
||||
if (query.trim().length === 0 || !team) {
|
||||
if (query.trim().length === 0 || !teamId) {
|
||||
return
|
||||
}
|
||||
const items = await octoClient.search(team.id, query)
|
||||
const items = await octoClient.search(teamId, query)
|
||||
|
||||
setResults(items)
|
||||
setIsSearching(false)
|
||||
}, [team?.id])
|
||||
}, [teamId])
|
||||
|
||||
const debouncedSearchHandler = useMemo(() => debounce(searchHandler, 200), [searchHandler])
|
||||
|
||||
const emptyResult = results.length === 0 && !isSearching && searchQuery
|
||||
|
||||
if (!team) {
|
||||
useWebsockets(teamId, (wsClient: WSClient) => {
|
||||
const onChangeBoardHandler = (_: WSClient, boards: Board[]): void => {
|
||||
const newResults = [...results]
|
||||
let updated = false
|
||||
results.forEach((board, idx) => {
|
||||
for (const newBoard of boards) {
|
||||
if (newBoard.id == board.id) {
|
||||
newResults[idx] = newBoard
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
})
|
||||
if (updated) {
|
||||
setResults(newResults)
|
||||
}
|
||||
}
|
||||
|
||||
wsClient.addOnChange(onChangeBoardHandler, 'board')
|
||||
|
||||
return () => {
|
||||
wsClient.removeOnChange(onChangeBoardHandler, 'board')
|
||||
}
|
||||
}, [results])
|
||||
|
||||
|
||||
if (!teamId) {
|
||||
return null
|
||||
}
|
||||
if (!currentChannel) {
|
||||
@ -68,34 +96,18 @@ const BoardSelector = () => {
|
||||
setShowLinkBoardConfirmation(board)
|
||||
return
|
||||
}
|
||||
const newBoard = createBoard(board)
|
||||
newBoard.channelId = currentChannel
|
||||
const newBoard = createBoard({...board, 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 = ''
|
||||
const newBoard = createBoard({...board, 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 board = {...createBoard(), teamId, channelId: currentChannel}
|
||||
|
||||
const view = createBoardView()
|
||||
view.fields.viewType = 'board'
|
||||
@ -111,7 +123,7 @@ const BoardSelector = () => {
|
||||
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}`)
|
||||
windowAny.WebappUtils.browserHistory.push(`/boards/team/${teamId}/${newBoard.id}`)
|
||||
dispatch(setLinkToChannel(''))
|
||||
},
|
||||
async () => {return},
|
||||
@ -122,7 +134,13 @@ const BoardSelector = () => {
|
||||
<div className='focalboard-body'>
|
||||
<Dialog
|
||||
className='BoardSelector'
|
||||
onClose={() => dispatch(setLinkToChannel(''))}
|
||||
onClose={() => {
|
||||
dispatch(setLinkToChannel(''))
|
||||
setResults([])
|
||||
setIsSearching(false)
|
||||
setSearchQuery('')
|
||||
setShowLinkBoardConfirmation(null)
|
||||
}}
|
||||
>
|
||||
{showLinkBoardConfirmation &&
|
||||
<ConfirmationDialog
|
||||
|
@ -29,6 +29,11 @@ exports[`components/boardsUnfurl/BoardsUnfurl renders normally 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="body"
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
<div
|
||||
class="footer"
|
||||
>
|
||||
@ -46,8 +51,7 @@ exports[`components/boardsUnfurl/BoardsUnfurl renders normally 1`] = `
|
||||
<span
|
||||
class="post-preview__time"
|
||||
>
|
||||
Updated
|
||||
|
||||
Updated January 01, 1970, 12:00 AM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,6 +10,7 @@ import {Provider as ReduxProvider} from 'react-redux'
|
||||
|
||||
import {mocked} from 'jest-mock'
|
||||
|
||||
import {Utils} from '../../../../../webapp/src/utils'
|
||||
import {createCard} from '../../../../../webapp/src/blocks/card'
|
||||
import {createBoard} from '../../../../../webapp/src/blocks/board'
|
||||
import octoClient from '../../../../../webapp/src/octoClient'
|
||||
@ -18,29 +19,39 @@ import {wrapIntl} from '../../../../../webapp/src/testUtils'
|
||||
import BoardsUnfurl from './boardsUnfurl'
|
||||
|
||||
jest.mock('../../../../../webapp/src/octoClient')
|
||||
jest.mock('../../../../../webapp/src/utils')
|
||||
const mockedOctoClient = mocked(octoClient, true)
|
||||
const mockedUtils = mocked(Utils, true)
|
||||
mockedUtils.createGuid = jest.requireActual('../../../../../webapp/src/utils').Utils.createGuid
|
||||
mockedUtils.blockTypeToIDType = jest.requireActual('../../../../../webapp/src/utils').Utils.blockTypeToIDType
|
||||
mockedUtils.displayDateTime = jest.requireActual('../../../../../webapp/src/utils').Utils.displayDateTime
|
||||
|
||||
describe('components/boardsUnfurl/BoardsUnfurl', () => {
|
||||
const team = {
|
||||
id: 'team-id',
|
||||
name: 'team',
|
||||
display_name: 'Team name',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// This is done to the websocket not to try to connect directly
|
||||
mockedUtils.isFocalboardPlugin.mockReturnValue(true)
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders normally', async () => {
|
||||
const mockStore = configureStore([])
|
||||
const store = mockStore({
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'id_1',
|
||||
profiles: {
|
||||
id_1: {
|
||||
locale: 'en',
|
||||
},
|
||||
},
|
||||
},
|
||||
language: {
|
||||
value: 'en',
|
||||
},
|
||||
teams: {
|
||||
allTeams: [team],
|
||||
current: team,
|
||||
},
|
||||
})
|
||||
|
||||
const cards = [{...createCard(), title: 'test card'}]
|
||||
const cards = [{...createCard(), title: 'test card', updateAt: 12345}]
|
||||
const board = {...createBoard(), title: 'test board'}
|
||||
|
||||
mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards)
|
||||
@ -50,7 +61,7 @@ describe('components/boardsUnfurl/BoardsUnfurl', () => {
|
||||
<ReduxProvider store={store}>
|
||||
{wrapIntl(
|
||||
<BoardsUnfurl
|
||||
embed={{data: '{"workspaceID": "foo", "cardID": "bar", "boardID": "baz", "readToken": "abc", "originalPath": "/test"}'}}
|
||||
embed={{data: JSON.stringify({workspaceID: "foo", cardID: cards[0].id, boardID: board.id, readToken: "abc", originalPath: "/test"})}}
|
||||
/>,
|
||||
)}
|
||||
</ReduxProvider>
|
||||
@ -62,6 +73,8 @@ describe('components/boardsUnfurl/BoardsUnfurl', () => {
|
||||
const result = render(component)
|
||||
container = result.container
|
||||
})
|
||||
expect(mockedOctoClient.getBoard).toBeCalledWith(board.id)
|
||||
expect(mockedOctoClient.getBlocksWithBlockID).toBeCalledWith(cards[0].id, board.id, "abc")
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
@ -69,19 +82,16 @@ describe('components/boardsUnfurl/BoardsUnfurl', () => {
|
||||
it('renders when limited', async () => {
|
||||
const mockStore = configureStore([])
|
||||
const store = mockStore({
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'id_1',
|
||||
profiles: {
|
||||
id_1: {
|
||||
locale: 'en',
|
||||
},
|
||||
},
|
||||
},
|
||||
language: {
|
||||
value: 'en',
|
||||
},
|
||||
teams: {
|
||||
allTeams: [team],
|
||||
current: team,
|
||||
},
|
||||
})
|
||||
|
||||
const cards = [{...createCard(), title: 'test card', limited: true}]
|
||||
const cards = [{...createCard(), title: 'test card', limited: true, updateAt: 12345}]
|
||||
const board = {...createBoard(), title: 'test board'}
|
||||
|
||||
mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards)
|
||||
@ -91,7 +101,7 @@ describe('components/boardsUnfurl/BoardsUnfurl', () => {
|
||||
<ReduxProvider store={store}>
|
||||
{wrapIntl(
|
||||
<BoardsUnfurl
|
||||
embed={{data: '{"workspaceID": "foo", "cardID": "bar", "boardID": "baz", "readToken": "abc", "originalPath": "/test"}'}}
|
||||
embed={{data: JSON.stringify({workspaceID: "foo", cardID: cards[0].id, boardID: board.id, readToken: "abc", originalPath: "/test"})}}
|
||||
/>,
|
||||
)}
|
||||
</ReduxProvider>
|
||||
|
@ -1,22 +1,29 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {IntlProvider, FormattedMessage} from 'react-intl'
|
||||
import {connect} from 'react-redux'
|
||||
import {IntlProvider, FormattedMessage, useIntl} from 'react-intl'
|
||||
|
||||
import {GlobalState} from 'mattermost-redux/types/store'
|
||||
import {getCurrentUserLocale} from 'mattermost-redux/selectors/entities/i18n'
|
||||
import WithWebSockets from '../../../../../webapp/src/components/withWebSockets'
|
||||
import {useWebsockets} from '../../../../../webapp/src/hooks/websockets'
|
||||
|
||||
import {getLanguage} from '../../../../../webapp/src/store/language'
|
||||
import {useAppSelector} from '../../../../../webapp/src/store/hooks'
|
||||
import {getCurrentTeamId} from '../../../../../webapp/src/store/teams'
|
||||
|
||||
import {WSClient, MMWebSocketClient} from '../../../../../webapp/src/wsclient'
|
||||
import manifest from '../../manifest'
|
||||
|
||||
import {getMessages} from './../../../../../webapp/src/i18n'
|
||||
import {Utils} from './../../../../../webapp/src/utils'
|
||||
import {Block} from './../../../../../webapp/src/blocks/block'
|
||||
import {Card} from './../../../../../webapp/src/blocks/card'
|
||||
import {Board} from './../../../../../webapp/src/blocks/board'
|
||||
import {ContentBlock} from './../../../../../webapp/src/blocks/contentBlock'
|
||||
|
||||
import octoClient from './../../../../../webapp/src/octoClient'
|
||||
|
||||
const noop = () => ''
|
||||
const Avatar = (window as any).Components?.Avatar || noop
|
||||
const Timestamp = (window as any).Components?.Timestamp || noop
|
||||
const imageURLForUser = (window as any).Components?.imageURLForUser || noop
|
||||
|
||||
import './boardsUnfurl.scss'
|
||||
@ -26,15 +33,7 @@ type Props = {
|
||||
embed: {
|
||||
data: string,
|
||||
},
|
||||
locale: string,
|
||||
}
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
const locale = getCurrentUserLocale(state)
|
||||
|
||||
return {
|
||||
locale,
|
||||
}
|
||||
webSocketClient?: MMWebSocketClient,
|
||||
}
|
||||
|
||||
class FocalboardEmbeddedData {
|
||||
@ -59,8 +58,11 @@ export const BoardsUnfurl = (props: Props): JSX.Element => {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const {embed, locale} = props
|
||||
const intl = useIntl()
|
||||
|
||||
const {embed, webSocketClient} = props
|
||||
const focalboardInformation: FocalboardEmbeddedData = new FocalboardEmbeddedData(embed.data)
|
||||
const currentTeamId = useAppSelector(getCurrentTeamId)
|
||||
const {teamID, cardID, boardID, readToken, originalPath} = focalboardInformation
|
||||
const baseURL = window.location.origin
|
||||
|
||||
@ -111,6 +113,26 @@ export const BoardsUnfurl = (props: Props): JSX.Element => {
|
||||
fetchData()
|
||||
}, [originalPath])
|
||||
|
||||
useWebsockets(currentTeamId, (wsClient: WSClient) => {
|
||||
const onChangeHandler = (_: WSClient, blocks: Block[]): void => {
|
||||
const cardBlock: Block|undefined = blocks.find(b => b.id === cardID)
|
||||
if (cardBlock && !cardBlock.deleteAt) {
|
||||
setCard(cardBlock as Card)
|
||||
}
|
||||
|
||||
const contentBlock: Block|undefined = blocks.find(b => b.id === content?.id)
|
||||
if (contentBlock && !contentBlock.deleteAt) {
|
||||
setContent(contentBlock)
|
||||
}
|
||||
}
|
||||
|
||||
wsClient.addOnChange(onChangeHandler, 'block')
|
||||
|
||||
return () => {
|
||||
wsClient.removeOnChange(onChangeHandler, 'block')
|
||||
}
|
||||
}, [cardID, content?.id])
|
||||
|
||||
let remainder = 0
|
||||
let html = ''
|
||||
const propertiesToDisplay: Array<Record<string, string>> = []
|
||||
@ -160,10 +182,7 @@ export const BoardsUnfurl = (props: Props): JSX.Element => {
|
||||
}
|
||||
|
||||
return (
|
||||
<IntlProvider
|
||||
messages={getMessages(locale)}
|
||||
locale={locale}
|
||||
>
|
||||
<WithWebSockets manifest={manifest} webSocketClient={webSocketClient}>
|
||||
{!loading && (!card || !board) && <></>}
|
||||
{!loading && card && board &&
|
||||
<a
|
||||
@ -192,77 +211,78 @@ export const BoardsUnfurl = (props: Props): JSX.Element => {
|
||||
}
|
||||
|
||||
{card.limited &&
|
||||
<p className='limited'>
|
||||
<FormattedMessage
|
||||
id='BoardsUnfurl.Limited'
|
||||
defaultMessage={'Additional details are hidden due to the card being archived'}
|
||||
/>
|
||||
</p>}
|
||||
<p className='limited'>
|
||||
<FormattedMessage
|
||||
id='BoardsUnfurl.Limited'
|
||||
defaultMessage={'Additional details are hidden due to the card being archived'}
|
||||
/>
|
||||
</p>}
|
||||
|
||||
{/* Footer of the Card*/}
|
||||
{!card.limited &&
|
||||
<div className='footer'>
|
||||
<div className='avatar'>
|
||||
<Avatar
|
||||
size={'md'}
|
||||
url={imageURLForUser(card.createdBy)}
|
||||
className={'avatar-post-preview'}
|
||||
/>
|
||||
</div>
|
||||
<div className='timestamp_properties'>
|
||||
<div className='properties'>
|
||||
{propertiesToDisplay.map((property) => (
|
||||
<div
|
||||
key={property.optionValue}
|
||||
className={`property ${property.optionValueColour}`}
|
||||
title={`${property.optionName}`}
|
||||
style={{maxWidth: `${(1 / propertiesToDisplay.length) * 100}%`}}
|
||||
>
|
||||
{property.optionValue}
|
||||
</div>
|
||||
))}
|
||||
{remainder > 0 &&
|
||||
<span className='remainder'>
|
||||
<FormattedMessage
|
||||
id='BoardsUnfurl.Remainder'
|
||||
defaultMessage='+{remainder} more'
|
||||
values={{
|
||||
remainder,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<span className='post-preview__time'>
|
||||
<FormattedMessage
|
||||
id='BoardsUnfurl.Updated'
|
||||
defaultMessage='Updated {time}'
|
||||
values={{
|
||||
time: (
|
||||
<Timestamp
|
||||
value={card.updateAt}
|
||||
units={[
|
||||
'now',
|
||||
'minute',
|
||||
'hour',
|
||||
'day',
|
||||
]}
|
||||
useTime={false}
|
||||
day={'numeric'}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
<div className='footer'>
|
||||
<div className='avatar'>
|
||||
<Avatar
|
||||
size={'md'}
|
||||
url={imageURLForUser(card.createdBy)}
|
||||
className={'avatar-post-preview'}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
<div className='timestamp_properties'>
|
||||
<div className='properties'>
|
||||
{propertiesToDisplay.map((property) => (
|
||||
<div
|
||||
key={property.optionValue}
|
||||
className={`property ${property.optionValueColour}`}
|
||||
title={`${property.optionName}`}
|
||||
style={{maxWidth: `${(1 / propertiesToDisplay.length) * 100}%`}}
|
||||
>
|
||||
{property.optionValue}
|
||||
</div>
|
||||
))}
|
||||
{remainder > 0 &&
|
||||
<span className='remainder'>
|
||||
<FormattedMessage
|
||||
id='BoardsUnfurl.Remainder'
|
||||
defaultMessage='+{remainder} more'
|
||||
values={{
|
||||
remainder,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<span className='post-preview__time'>
|
||||
<FormattedMessage
|
||||
id='BoardsUnfurl.Updated'
|
||||
defaultMessage='Updated {time}'
|
||||
values={{
|
||||
time: Utils.displayDateTime(new Date(card.updateAt), intl)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>}
|
||||
</a>
|
||||
}
|
||||
{loading &&
|
||||
<div style={{height: '302px'}}/>
|
||||
}
|
||||
</WithWebSockets>
|
||||
)
|
||||
}
|
||||
|
||||
const IntlBoardsUnfurl = (props: Props) => {
|
||||
const language = useAppSelector<string>(getLanguage)
|
||||
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={language.split(/[_]/)[0]}
|
||||
messages={getMessages(language)}
|
||||
>
|
||||
<BoardsUnfurl {...props}/>
|
||||
</IntlProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(BoardsUnfurl)
|
||||
export default IntlBoardsUnfurl
|
||||
|
@ -4,12 +4,18 @@
|
||||
import React from 'react'
|
||||
import {Provider as ReduxProvider} from 'react-redux'
|
||||
import {render} from '@testing-library/react'
|
||||
import {mocked} from 'jest-mock'
|
||||
import thunk from 'redux-thunk'
|
||||
|
||||
import octoClient from '../../../../webapp/src/octoClient'
|
||||
import {createBoard} from '../../../../webapp/src/blocks/board'
|
||||
import {mockStateStore, wrapIntl} from '../../../../webapp/src/testUtils'
|
||||
|
||||
import RHSChannelBoards from './rhsChannelBoards'
|
||||
|
||||
jest.mock('../../../../webapp/src/octoClient')
|
||||
const mockedOctoClient = mocked(octoClient, true)
|
||||
|
||||
describe('components/rhsChannelBoards', () => {
|
||||
const board1 = createBoard()
|
||||
board1.updateAt = 1657311058157
|
||||
@ -29,6 +35,7 @@ describe('components/rhsChannelBoards', () => {
|
||||
teams: {
|
||||
allTeams: [team],
|
||||
current: team,
|
||||
currentId: team.id,
|
||||
},
|
||||
language: {
|
||||
value: 'en',
|
||||
@ -55,8 +62,13 @@ describe('components/rhsChannelBoards', () => {
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedOctoClient.getBoards.mockResolvedValue([board1, board2, board3])
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the RHS for channel boards', async () => {
|
||||
const store = mockStateStore([], state)
|
||||
const store = mockStateStore([thunk], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoards/>
|
||||
@ -67,7 +79,7 @@ describe('components/rhsChannelBoards', () => {
|
||||
|
||||
it('renders with empty list of boards', async () => {
|
||||
const localState = {...state, boards: {...state.boards, boards: {}}}
|
||||
const store = mockStateStore([], localState)
|
||||
const store = mockStateStore([thunk], localState)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoards/>
|
||||
|
@ -1,18 +1,24 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import React, {useEffect} 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 {useWebsockets} from '../../../../webapp/src/hooks/websockets'
|
||||
|
||||
import {Board, BoardMember} from '../../../../webapp/src/blocks/board'
|
||||
import {getCurrentTeamId} from '../../../../webapp/src/store/teams'
|
||||
import {loadBoards} from '../../../../webapp/src/store/initialLoad'
|
||||
import {getCurrentChannel} from '../../../../webapp/src/store/channels'
|
||||
import {getMySortedBoards, setLinkToChannel} from '../../../../webapp/src/store/boards'
|
||||
import {getMySortedBoards, setLinkToChannel, updateBoards, updateMembers} 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 {WSClient} from '../../../../webapp/src/wsclient'
|
||||
|
||||
import RHSChannelBoardItem from './rhsChannelBoardItem'
|
||||
|
||||
import './rhsChannelBoards.scss'
|
||||
@ -21,14 +27,35 @@ const boardsScreenshots = (window as any).baseURL + '/public/boards-screenshots.
|
||||
|
||||
const RHSChannelBoards = () => {
|
||||
const boards = useAppSelector(getMySortedBoards)
|
||||
const team = useAppSelector(getCurrentTeam)
|
||||
const teamId = useAppSelector(getCurrentTeamId)
|
||||
const currentChannel = useAppSelector(getCurrentChannel)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadBoards())
|
||||
}, [])
|
||||
|
||||
useWebsockets(teamId || '', (wsClient: WSClient) => {
|
||||
const onChangeBoardHandler = (_: WSClient, boards: Board[]): void => {
|
||||
dispatch(updateBoards(boards))
|
||||
}
|
||||
const onChangeMemberHandler = (_: WSClient, members: BoardMember[]): void => {
|
||||
dispatch(updateMembers(members))
|
||||
}
|
||||
|
||||
wsClient.addOnChange(onChangeBoardHandler, 'board')
|
||||
wsClient.addOnChange(onChangeMemberHandler, 'boardMembers')
|
||||
|
||||
return () => {
|
||||
wsClient.removeOnChange(onChangeBoardHandler, 'board')
|
||||
wsClient.removeOnChange(onChangeMemberHandler, 'boardMembers')
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!boards) {
|
||||
return null
|
||||
}
|
||||
if (!team) {
|
||||
if (!teamId) {
|
||||
return null
|
||||
}
|
||||
if (!currentChannel) {
|
||||
|
@ -23,6 +23,7 @@ windowAny.isFocalboardPlugin = true
|
||||
import App from '../../../webapp/src/app'
|
||||
import store from '../../../webapp/src/store'
|
||||
import {setTeam} from '../../../webapp/src/store/teams'
|
||||
import WithWebSockets from '../../../webapp/src/components/withWebSockets'
|
||||
import {setChannel} from '../../../webapp/src/store/channels'
|
||||
import {initialLoad} from '../../../webapp/src/store/initialLoad'
|
||||
import {Utils} from '../../../webapp/src/utils'
|
||||
@ -129,8 +130,6 @@ function customHistory() {
|
||||
let browserHistory: History<unknown>
|
||||
|
||||
const MainApp = (props: Props) => {
|
||||
wsClient.initPlugin(manifest.id, manifest.version, props.webSocketClient)
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add('focalboard-body')
|
||||
document.body.classList.add('app__body')
|
||||
@ -151,10 +150,12 @@ const MainApp = (props: Props) => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ReduxProvider store={store}>
|
||||
<div id='focalboard-app'>
|
||||
<App history={browserHistory}/>
|
||||
</div>
|
||||
<div id='focalboard-root-portal'/>
|
||||
<WithWebSockets manifest={manifest} webSocketClient={props.webSocketClient}>
|
||||
<div id='focalboard-app'>
|
||||
<App history={browserHistory}/>
|
||||
</div>
|
||||
<div id='focalboard-root-portal'/>
|
||||
</WithWebSockets>
|
||||
</ReduxProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
@ -187,6 +188,36 @@ export default class Plugin {
|
||||
UserSettings.nameFormat = mmStore.getState().entities.preferences?.myPreferences['display_settings--name_format']?.value || null
|
||||
let theme = mmStore.getState().entities.preferences.myPreferences.theme
|
||||
setMattermostTheme(theme)
|
||||
|
||||
// register websocket handlers
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD}`, (e: any) => wsClient.updateHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CARD_LIMIT_TIMESTAMP}`, (e: any) => wsClient.updateCardLimitTimestampHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data))
|
||||
this.registry?.registerPostTypeComponent('custom_cloud_upgrade_nudge', CloudUpgradeNudge)
|
||||
this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => {
|
||||
let preferences
|
||||
try {
|
||||
preferences = JSON.parse(e.data.preferences)
|
||||
} catch {
|
||||
preferences = []
|
||||
}
|
||||
if (preferences) {
|
||||
for (const preference of preferences) {
|
||||
if (preference.category === 'theme' && theme !== preference.value) {
|
||||
setMattermostTheme(JSON.parse(preference.value))
|
||||
theme = preference.value
|
||||
}
|
||||
if(preference.category === 'display_settings' && preference.name === 'name_format'){
|
||||
UserSettings.nameFormat = preference.value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let lastViewedChannel = mmStore.getState().entities.channels.currentChannelId
|
||||
let prevTeamID: string
|
||||
|
||||
@ -210,10 +241,11 @@ export default class Plugin {
|
||||
if (currentTeamID && currentTeamID !== prevTeamID) {
|
||||
if (prevTeamID && window.location.pathname.startsWith(windowAny.frontendBaseURL || '')) {
|
||||
browserHistory.push(`/team/${currentTeamID}`)
|
||||
wsClient.subscribeToTeam(currentTeamID)
|
||||
}
|
||||
prevTeamID = currentTeamID
|
||||
store.dispatch(setTeam(currentTeamID))
|
||||
octoClient.teamId = currentTeamID
|
||||
store.dispatch(initialLoad())
|
||||
}
|
||||
})
|
||||
|
||||
@ -221,9 +253,11 @@ export default class Plugin {
|
||||
windowAny.frontendBaseURL = subpath + '/boards'
|
||||
|
||||
const {rhsId, toggleRHSPlugin} = this.registry.registerRightHandSidebarComponent(
|
||||
() => (
|
||||
(props: {webSocketClient: MMWebSocketClient}) => (
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoards/>
|
||||
<WithWebSockets manifest={manifest} webSocketClient={props.webSocketClient}>
|
||||
<RHSChannelBoards/>
|
||||
</WithWebSockets>
|
||||
</ReduxProvider>
|
||||
),
|
||||
<ErrorBoundary>
|
||||
@ -263,12 +297,25 @@ export default class Plugin {
|
||||
this.registry.registerAppBarComponent(appBarIconURL, () => mmStore.dispatch(toggleRHSPlugin), 'Boards')
|
||||
}
|
||||
|
||||
this.registry.registerPostWillRenderEmbedComponent((embed) => embed.type === 'boards', BoardsUnfurl, false)
|
||||
this.registry.registerPostWillRenderEmbedComponent(
|
||||
(embed) => embed.type === 'boards',
|
||||
(props: {embed: {data: string}, webSocketClient: MMWebSocketClient}) => (
|
||||
<ReduxProvider store={store}>
|
||||
<BoardsUnfurl
|
||||
embed={props.embed}
|
||||
webSocketClient={props.webSocketClient}
|
||||
/>
|
||||
</ReduxProvider>
|
||||
),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
this.boardSelectorId = this.registry.registerRootComponent(() => (
|
||||
this.boardSelectorId = this.registry.registerRootComponent((props: {webSocketClient: MMWebSocketClient}) => (
|
||||
<ReduxProvider store={store}>
|
||||
<BoardSelector/>
|
||||
<WithWebSockets manifest={manifest} webSocketClient={props.webSocketClient}>
|
||||
<BoardSelector/>
|
||||
</WithWebSockets>
|
||||
</ReduxProvider>
|
||||
))
|
||||
|
||||
@ -307,34 +354,6 @@ export default class Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
// register websocket handlers
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD}`, (e: any) => wsClient.updateHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CARD_LIMIT_TIMESTAMP}`, (e: any) => wsClient.updateCardLimitTimestampHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data))
|
||||
this.registry?.registerPostTypeComponent('custom_cloud_upgrade_nudge', CloudUpgradeNudge)
|
||||
this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => {
|
||||
let preferences
|
||||
try {
|
||||
preferences = JSON.parse(e.data.preferences)
|
||||
} catch {
|
||||
preferences = []
|
||||
}
|
||||
if (preferences) {
|
||||
for (const preference of preferences) {
|
||||
if (preference.category === 'theme' && theme !== preference.value) {
|
||||
setMattermostTheme(JSON.parse(preference.value))
|
||||
theme = preference.value
|
||||
}
|
||||
if(preference.category === 'display_settings' && preference.name === 'name_format'){
|
||||
UserSettings.nameFormat = preference.value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
windowAny.setTeamInSidebar = (teamID: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
@ -345,7 +364,6 @@ export default class Plugin {
|
||||
// @ts-ignore
|
||||
return mmStore.getState().entities.teams.currentTeamId
|
||||
}
|
||||
store.dispatch(initialLoad())
|
||||
}
|
||||
|
||||
uninitialize(): void {
|
||||
|
@ -98,6 +98,7 @@ func (a *API) RegisterRoutes(r *mux.Router) {
|
||||
apiv2.HandleFunc("/teams/{teamID}/boards/search", a.sessionRequired(a.handleSearchBoards)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/templates", a.sessionRequired(a.handleGetTemplates)).Methods("GET")
|
||||
apiv2.HandleFunc("/boards", a.sessionRequired(a.handleCreateBoard)).Methods("POST")
|
||||
apiv2.HandleFunc("/boards/search", a.sessionRequired(a.handleSearchAllBoards)).Methods("GET")
|
||||
apiv2.HandleFunc("/boards/{boardID}", a.attachSession(a.handleGetBoard, false)).Methods("GET")
|
||||
apiv2.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handlePatchBoard)).Methods("PATCH")
|
||||
apiv2.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handleDeleteBoard)).Methods("DELETE")
|
||||
@ -3366,7 +3367,7 @@ func (a *API) handleGetBoardMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/boards/search searchBoards
|
||||
//
|
||||
// Returns the boards that match with a search term
|
||||
// Returns the boards that match with a search term in the team
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
@ -3415,7 +3416,7 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.SearchBoardsForUser(term, userID)
|
||||
boards, err := a.app.SearchBoardsForUserInTeam(teamID, term, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
@ -3439,6 +3440,69 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleSearchAllBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /boards/search searchBoards
|
||||
//
|
||||
// Returns the boards that match with a search term
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: q
|
||||
// in: query
|
||||
// description: The search term. Must have at least one character
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Board"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
term := r.URL.Query().Get("q")
|
||||
userID := getUserID(r)
|
||||
|
||||
if len(term) == 0 {
|
||||
jsonStringResponse(w, http.StatusOK, "[]")
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "searchAllBoards", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.SearchBoardsForUser(term, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("SearchAllBoards",
|
||||
mlog.Int("boardsCount", len(boards)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(boards)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("boardsCount", len(boards))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetMembersForBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /boards/{boardID}/members getMembersForBoard
|
||||
//
|
||||
|
@ -444,6 +444,10 @@ func (a *App) SearchBoardsForUser(term, userID string) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUser(term, userID)
|
||||
}
|
||||
|
||||
func (a *App) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUserInTeam(teamID, term, userID)
|
||||
}
|
||||
|
||||
func (a *App) UndeleteBoard(boardID string, modifiedBy string) error {
|
||||
boards, err := a.store.GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true})
|
||||
if err != nil {
|
||||
|
@ -526,7 +526,7 @@ func TestSearchBoards(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
board5 := &model.Board{
|
||||
Title: "public board where user1 is admin, but in other team",
|
||||
Title: "private board where user1 is admin, but in other team",
|
||||
Type: model.BoardTypePrivate,
|
||||
TeamID: "other-team-id",
|
||||
}
|
||||
@ -543,25 +543,25 @@ func TestSearchBoards(t *testing.T) {
|
||||
Name: "should return all boards where user1 is member or that are public",
|
||||
Client: th.Client,
|
||||
Term: "board",
|
||||
ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, rBoard3.ID, board5.ID},
|
||||
ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, rBoard3.ID},
|
||||
},
|
||||
{
|
||||
Name: "matching a full word",
|
||||
Client: th.Client,
|
||||
Term: "admin",
|
||||
ExpectedIDs: []string{rBoard1.ID, rBoard3.ID, board5.ID},
|
||||
ExpectedIDs: []string{rBoard1.ID, rBoard3.ID},
|
||||
},
|
||||
{
|
||||
Name: "matching part of the word",
|
||||
Client: th.Client,
|
||||
Term: "ubli",
|
||||
ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, board5.ID},
|
||||
ExpectedIDs: []string{rBoard1.ID, rBoard2.ID},
|
||||
},
|
||||
{
|
||||
Name: "case insensitive",
|
||||
Client: th.Client,
|
||||
Term: "UBLI",
|
||||
ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, board5.ID},
|
||||
ExpectedIDs: []string{rBoard1.ID, rBoard2.ID},
|
||||
},
|
||||
{
|
||||
Name: "user2 can only see the public boards, as he's not a member of any",
|
||||
@ -585,50 +585,6 @@ func TestSearchBoards(t *testing.T) {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should only return boards in the teams that the user belongs to", func(t *testing.T) {
|
||||
th := SetupTestHelperPluginMode(t)
|
||||
defer th.TearDown()
|
||||
clients := setupClients(th)
|
||||
|
||||
b1, err := th.Server.App().CreateBoard(
|
||||
&model.Board{Title: "public board", Type: model.BoardTypeOpen, TeamID: "test-team"},
|
||||
userAdmin,
|
||||
true,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = th.Server.App().CreateBoard(
|
||||
&model.Board{Title: "private board", Type: model.BoardTypePrivate, TeamID: "test-team"},
|
||||
userAdmin,
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = th.Server.App().CreateBoard(
|
||||
&model.Board{Title: "public board in empty team", Type: model.BoardTypeOpen, TeamID: "empty-team"},
|
||||
userAdmin,
|
||||
true,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = th.Server.App().CreateBoard(
|
||||
&model.Board{Title: "private board in empty team", Type: model.BoardTypePrivate, TeamID: "empty-team"},
|
||||
userAdmin,
|
||||
true,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
boards, resp := clients.Viewer.SearchBoardsForTeam(testTeamID, "board")
|
||||
th.CheckOK(resp)
|
||||
|
||||
boardIDs := []string{}
|
||||
for _, board := range boards {
|
||||
boardIDs = append(boardIDs, board.ID)
|
||||
}
|
||||
|
||||
require.ElementsMatch(t, []string{b1.ID}, boardIDs)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetBoard(t *testing.T) {
|
||||
|
@ -1278,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)
|
||||
}
|
||||
|
||||
// SearchBoardsForUserInTeam mocks base method.
|
||||
func (m *MockStore) SearchBoardsForUserInTeam(arg0, arg1, arg2 string) ([]*model.Board, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SearchBoardsForUserInTeam", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].([]*model.Board)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SearchBoardsForUserInTeam indicates an expected call of SearchBoardsForUserInTeam.
|
||||
func (mr *MockStoreMockRecorder) SearchBoardsForUserInTeam(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchBoardsForUserInTeam", reflect.TypeOf((*MockStore)(nil).SearchBoardsForUserInTeam), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// SearchUserChannels mocks base method.
|
||||
func (m *MockStore) SearchUserChannels(arg0, arg1, arg2 string) ([]*model0.Channel, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -684,6 +684,52 @@ func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term, userID string) ([
|
||||
return s.boardsFromRows(rows)
|
||||
}
|
||||
|
||||
// searchBoardsForUserInTeam returns all boards that match with the
|
||||
// term that are either private and which the user is a member of, or
|
||||
// they're open, regardless of the user membership.
|
||||
// Search is case-insensitive.
|
||||
func (s *SQLStore) searchBoardsForUserInTeam(db sq.BaseRunner, teamID, term, userID string) ([]*model.Board, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardFields("b.")...).
|
||||
Distinct().
|
||||
From(s.tablePrefix + "boards as b").
|
||||
LeftJoin(s.tablePrefix + "board_members as bm on b.id=bm.board_id").
|
||||
Where(sq.Eq{"b.is_template": false}).
|
||||
Where(sq.Eq{"b.team_id": teamID}).
|
||||
Where(sq.Or{
|
||||
sq.Eq{"b.type": model.BoardTypeOpen},
|
||||
sq.And{
|
||||
sq.Eq{"b.type": model.BoardTypePrivate},
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
},
|
||||
})
|
||||
|
||||
if term != "" {
|
||||
// break search query into space separated words
|
||||
// and search for each word.
|
||||
// This should later be upgraded to industrial-strength
|
||||
// word tokenizer, that uses much more than space
|
||||
// to break words.
|
||||
|
||||
conditions := sq.Or{}
|
||||
|
||||
for _, word := range strings.Split(strings.TrimSpace(term), " ") {
|
||||
conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"})
|
||||
}
|
||||
|
||||
query = query.Where(conditions)
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`searchBoardsForUser ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
return s.boardsFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardHistory(db sq.BaseRunner, boardID string, opts model.QueryBoardHistoryOptions) ([]*model.Board, error) {
|
||||
var order string
|
||||
if opts.Descending {
|
||||
|
@ -388,6 +388,7 @@ func (s *SQLStore) createTempSchemaTable() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) populateTempSchemaTable(migrations []*models.Migration, legacySchemaVersion uint32) error {
|
||||
query := s.getQueryBuilder(s.db).
|
||||
Insert(s.tablePrefix+tempSchemaMigrationTableName).
|
||||
|
@ -741,6 +741,11 @@ func (s *SQLStore) SearchBoardsForUser(term string, userID string) ([]*model.Boa
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SearchBoardsForUserInTeam(teamID string, term string, userID string) ([]*model.Board, error) {
|
||||
return s.searchBoardsForUserInTeam(s.db, teamID, term, userID)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SearchUserChannels(teamID string, userID string, query string) ([]*mmModel.Channel, error) {
|
||||
return s.searchUserChannels(s.db, teamID, userID, query)
|
||||
|
||||
|
@ -101,6 +101,7 @@ type Store interface {
|
||||
GetMembersForBoard(boardID string) ([]*model.BoardMember, error)
|
||||
GetMembersForUser(userID string) ([]*model.BoardMember, error)
|
||||
SearchBoardsForUser(term, userID string) ([]*model.Board, error)
|
||||
SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error)
|
||||
|
||||
// @withTransaction
|
||||
CreateBoardsAndBlocksWithAdmin(bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, []*model.BoardMember, error)
|
||||
|
@ -13,7 +13,6 @@ import {getMessages} from './i18n'
|
||||
import {FlashMessages} from './components/flashMessages'
|
||||
import NewVersionBanner from './components/newVersionBanner'
|
||||
import {Utils} from './utils'
|
||||
import wsClient from './wsclient'
|
||||
import {fetchMe, getMe} from './store/users'
|
||||
import {getLanguage, fetchLanguage} from './store/language'
|
||||
import {useAppSelector, useAppDispatch} from './store/hooks'
|
||||
@ -37,22 +36,6 @@ const App = (props: Props): JSX.Element => {
|
||||
dispatch(fetchClientConfig())
|
||||
}, [])
|
||||
|
||||
// this is a temporary solution while we're using legacy routes
|
||||
// for shared boards as a way to disable websockets, and should be
|
||||
// removed when anonymous plugin routes are implemented. This
|
||||
// check is used to detect if we're running inside the plugin but
|
||||
// in a legacy route
|
||||
useEffect(() => {
|
||||
if (!Utils.isFocalboardLegacy()) {
|
||||
wsClient.open()
|
||||
}
|
||||
return () => {
|
||||
if (!Utils.isFocalboardLegacy()) {
|
||||
wsClient.close()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (me) {
|
||||
TelemetryClient.setUser(me)
|
||||
|
@ -55,7 +55,7 @@ const BoardSwitcherDialog = (props: Props): JSX.Element => {
|
||||
return []
|
||||
}
|
||||
|
||||
const items = await octoClient.search(team.id, query)
|
||||
const items = await octoClient.searchAll(query)
|
||||
const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'})
|
||||
return items.map((item) => {
|
||||
const resultTitle = item.title || untitledBoardTitle
|
||||
|
79
webapp/src/components/withWebSockets.tsx
Normal file
79
webapp/src/components/withWebSockets.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useEffect} from 'react'
|
||||
|
||||
import wsClient, {MMWebSocketClient} from '../wsclient'
|
||||
import {Utils} from '../utils'
|
||||
|
||||
type Props = {
|
||||
userId?: string,
|
||||
manifest?: {
|
||||
id: string,
|
||||
version: string,
|
||||
},
|
||||
webSocketClient?: MMWebSocketClient,
|
||||
children: React.ReactNode,
|
||||
}
|
||||
|
||||
// WithWebSockets component initialises the websocket connection if
|
||||
// it's not yet running and subscribes to the current team
|
||||
const WithWebSockets = (props: Props): React.ReactElement => {
|
||||
const queryString = new URLSearchParams(window.location.search)
|
||||
|
||||
useEffect(() => {
|
||||
// if the websocket client was already connected, do nothing
|
||||
if (wsClient.state !== 'init') {
|
||||
return
|
||||
}
|
||||
|
||||
// this is a temporary solution to disable websocket
|
||||
// connections on legacy routes, as there is no such thing as
|
||||
// an anonymous websocket connection
|
||||
if (Utils.isFocalboardLegacy()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!Utils.isFocalboardPlugin()) {
|
||||
const token = localStorage.getItem('focalboardSessionId') || queryString.get('r') || ''
|
||||
if (token) {
|
||||
wsClient.authenticate(token)
|
||||
}
|
||||
wsClient.open()
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.webSocketClient) {
|
||||
Utils.logWarn('Trying to initialise Boards websocket in plugin mode without base connection. Aborting')
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.manifest?.id || !props.manifest?.version) {
|
||||
Utils.logError('Trying to initialise Boards websocket in plugin mode with an incomplete manifest. Aborting')
|
||||
return
|
||||
}
|
||||
|
||||
wsClient.initPlugin(props.manifest?.id, props.manifest?.version, props.webSocketClient)
|
||||
wsClient.open()
|
||||
}, [props.webSocketClient])
|
||||
|
||||
useEffect(() => {
|
||||
// if we're running on a plugin instance or we don't have a
|
||||
// user yet, do nothing
|
||||
if (Utils.isFocalboardPlugin() || !props.userId) {
|
||||
return
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('focalboardSessionId') || queryString.get('r') || ''
|
||||
if (wsClient.token !== token) {
|
||||
wsClient.authenticate(token)
|
||||
}
|
||||
}, [props.userId])
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WithWebSockets
|
21
webapp/src/hooks/websockets.tsx
Normal file
21
webapp/src/hooks/websockets.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {useEffect} from 'react'
|
||||
|
||||
import wsClient, {WSClient} from '../wsclient'
|
||||
|
||||
export const useWebsockets = (teamId: string, fn: (wsClient: WSClient) => () => void, deps: any[] = []): void => {
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return
|
||||
}
|
||||
|
||||
wsClient.subscribeToTeam(teamId)
|
||||
const teardown = fn(wsClient)
|
||||
|
||||
return () => {
|
||||
teardown()
|
||||
wsClient.unsubscribeToTeam(teamId)
|
||||
}
|
||||
}, [teamId, ...deps])
|
||||
}
|
@ -10,6 +10,10 @@ import {initThemes} from './theme'
|
||||
import {importNativeAppSettings} from './nativeApp'
|
||||
import {UserSettings} from './userSettings'
|
||||
|
||||
import {IUser} from './user'
|
||||
import {getMe} from './store/users'
|
||||
import {useAppSelector} from './store/hooks'
|
||||
|
||||
import '@mattermost/compass-icons/css/compass-icons.css'
|
||||
|
||||
import './styles/variables.scss'
|
||||
@ -18,15 +22,27 @@ import './styles/labels.scss'
|
||||
import './styles/_markdown.scss'
|
||||
|
||||
import store from './store'
|
||||
import WithWebSockets from './components/withWebSockets'
|
||||
|
||||
emojiMartStore.setHandlers({getter: UserSettings.getEmojiMartSetting, setter: UserSettings.setEmojiMartSetting})
|
||||
importNativeAppSettings()
|
||||
|
||||
initThemes()
|
||||
|
||||
const MainApp = () => {
|
||||
const me = useAppSelector<IUser|null>(getMe)
|
||||
|
||||
return (
|
||||
<WithWebSockets userId={me?.id}>
|
||||
<App/>
|
||||
</WithWebSockets>
|
||||
)
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
(
|
||||
<ReduxProvider store={store}>
|
||||
<App/>
|
||||
<MainApp/>
|
||||
</ReduxProvider>
|
||||
),
|
||||
document.getElementById('focalboard-app'),
|
||||
|
@ -773,7 +773,21 @@ class OctoClient {
|
||||
}
|
||||
|
||||
async search(teamID: string, query: string): Promise<Array<Board>> {
|
||||
const url = `${this.teamPath()}/boards/search?q=${encodeURIComponent(query)}`
|
||||
const url = `${this.teamPath(teamID)}/boards/search?q=${encodeURIComponent(query)}`
|
||||
const response = await fetch(this.getBaseURL() + url, {
|
||||
method: 'GET',
|
||||
headers: this.headers(),
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
return []
|
||||
}
|
||||
|
||||
return (await this.getJson(response, [])) as Array<Board>
|
||||
}
|
||||
|
||||
async searchAll(query: string): Promise<Array<Board>> {
|
||||
const url = `/api/v2/boards/search?q=${encodeURIComponent(query)}`
|
||||
const response = await fetch(this.getBaseURL() + url, {
|
||||
method: 'GET',
|
||||
headers: this.headers(),
|
||||
|
@ -1,18 +1,43 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useEffect, useState, useMemo, useCallback} from 'react'
|
||||
import {batch} from 'react-redux'
|
||||
import {FormattedMessage, useIntl} from 'react-intl'
|
||||
import {useRouteMatch} from 'react-router-dom'
|
||||
|
||||
import Workspace from '../../components/workspace'
|
||||
import CloudMessage from '../../components/messages/cloudMessage'
|
||||
import octoClient from '../../octoClient'
|
||||
import {Subscription, WSClient} from '../../wsclient'
|
||||
import {Utils} from '../../utils'
|
||||
import wsClient from '../../wsclient'
|
||||
import {getCurrentBoardId, setCurrent as setCurrentBoard, fetchBoardMembers} from '../../store/boards'
|
||||
import {useWebsockets} from '../../hooks/websockets'
|
||||
import {IUser} from '../../user'
|
||||
import {Block} from '../../blocks/block'
|
||||
import {ContentBlock} from '../../blocks/contentBlock'
|
||||
import {CommentBlock} from '../../blocks/commentBlock'
|
||||
import {Board, BoardMember} from '../../blocks/board'
|
||||
import {BoardView} from '../../blocks/boardView'
|
||||
import {Card} from '../../blocks/card'
|
||||
import {
|
||||
updateBoards,
|
||||
updateMembersEnsuringBoardsAndUsers,
|
||||
getCurrentBoardId,
|
||||
setCurrent as setCurrentBoard,
|
||||
fetchBoardMembers,
|
||||
} from '../../store/boards'
|
||||
import {getCurrentViewId, setCurrent as setCurrentView} from '../../store/views'
|
||||
import {initialLoad, initialReadOnlyLoad, loadBoardData} from '../../store/initialLoad'
|
||||
import {useAppSelector, useAppDispatch} from '../../store/hooks'
|
||||
import {updateViews} from '../../store/views'
|
||||
import {updateCards} from '../../store/cards'
|
||||
import {updateComments} from '../../store/comments'
|
||||
import {updateContents} from '../../store/contents'
|
||||
import {
|
||||
fetchUserBlockSubscriptions,
|
||||
getMe,
|
||||
followBlock,
|
||||
unfollowBlock,
|
||||
} from '../../store/users'
|
||||
import {setGlobalError} from '../../store/globalError'
|
||||
import {UserSettings} from '../../userSettings'
|
||||
|
||||
@ -20,8 +45,6 @@ import IconButton from '../../widgets/buttons/iconButton'
|
||||
import CloseIcon from '../../widgets/icons/close'
|
||||
|
||||
import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../telemetry/telemetryClient'
|
||||
import {fetchUserBlockSubscriptions, getMe} from '../../store/users'
|
||||
import {IUser} from '../../user'
|
||||
|
||||
import {Constants} from "../../constants"
|
||||
|
||||
@ -45,6 +68,7 @@ const BoardPage = (props: Props): JSX.Element => {
|
||||
const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string, teamId?: string}>()
|
||||
const [mobileWarningClosed, setMobileWarningClosed] = useState(UserSettings.mobileWarningClosed)
|
||||
const teamId = match.params.teamId || UserSettings.lastTeamId || Constants.globalTeamId
|
||||
const viewId = match.params.viewId
|
||||
const me = useAppSelector<IUser|null>(getMe)
|
||||
|
||||
// if we're in a legacy route and not showing a shared board,
|
||||
@ -68,7 +92,6 @@ const BoardPage = (props: Props): JSX.Element => {
|
||||
useEffect(() => {
|
||||
UserSettings.lastTeamId = teamId
|
||||
octoClient.teamId = teamId
|
||||
wsClient.teamId = teamId
|
||||
const windowAny = (window as any)
|
||||
if (windowAny.setTeamInSidebar) {
|
||||
windowAny.setTeamInSidebar(teamId)
|
||||
@ -82,6 +105,52 @@ const BoardPage = (props: Props): JSX.Element => {
|
||||
return initialLoad
|
||||
}, [props.readonly])
|
||||
|
||||
useWebsockets(teamId, (wsClient) => {
|
||||
const incrementalBlockUpdate = (_: WSClient, blocks: Block[]) => {
|
||||
const teamBlocks = blocks
|
||||
|
||||
batch(() => {
|
||||
dispatch(updateViews(teamBlocks.filter((b: Block) => b.type === 'view' || b.deleteAt !== 0) as BoardView[]))
|
||||
dispatch(updateCards(teamBlocks.filter((b: Block) => b.type === 'card' || b.deleteAt !== 0) as Card[]))
|
||||
dispatch(updateComments(teamBlocks.filter((b: Block) => b.type === 'comment' || b.deleteAt !== 0) as CommentBlock[]))
|
||||
dispatch(updateContents(teamBlocks.filter((b: Block) => b.type !== 'card' && b.type !== 'view' && b.type !== 'board' && b.type !== 'comment') as ContentBlock[]))
|
||||
})
|
||||
}
|
||||
|
||||
const incrementalBoardUpdate = (_: WSClient, boards: Board[]) => {
|
||||
// only takes into account the entities that belong to the team or the user boards
|
||||
const teamBoards = boards.filter((b: Board) => b.teamId === Constants.globalTeamId || b.teamId === teamId)
|
||||
dispatch(updateBoards(teamBoards))
|
||||
}
|
||||
|
||||
const incrementalBoardMemberUpdate = (_: WSClient, members: BoardMember[]) => {
|
||||
dispatch(updateMembersEnsuringBoardsAndUsers(members))
|
||||
}
|
||||
|
||||
wsClient.addOnChange(incrementalBlockUpdate, 'block')
|
||||
wsClient.addOnChange(incrementalBoardUpdate, 'board')
|
||||
wsClient.addOnChange(incrementalBoardMemberUpdate, 'boardMembers')
|
||||
wsClient.addOnReconnect(() => dispatch(loadAction(match.params.boardId)))
|
||||
|
||||
wsClient.setOnFollowBlock((_: WSClient, subscription: Subscription): void => {
|
||||
if (subscription.subscriberId === me?.id) {
|
||||
dispatch(followBlock(subscription))
|
||||
}
|
||||
})
|
||||
wsClient.setOnUnfollowBlock((_: WSClient, subscription: Subscription): void => {
|
||||
if (subscription.subscriberId === me?.id) {
|
||||
dispatch(unfollowBlock(subscription))
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
wsClient.removeOnChange(incrementalBlockUpdate, 'block')
|
||||
wsClient.removeOnChange(incrementalBoardUpdate, 'board')
|
||||
wsClient.removeOnChange(incrementalBoardMemberUpdate, 'boardMembers')
|
||||
wsClient.removeOnReconnect(() => dispatch(loadAction(match.params.boardId)))
|
||||
}
|
||||
})
|
||||
|
||||
const loadOrJoinBoard = useCallback(async (userId: string, boardTeamId: string, boardId: string) => {
|
||||
// and fetch its data
|
||||
const result: any = await dispatch(loadBoardData(boardId))
|
||||
@ -112,16 +181,16 @@ const BoardPage = (props: Props): JSX.Element => {
|
||||
// and set it as most recently viewed board
|
||||
UserSettings.setLastBoardID(teamId, match.params.boardId)
|
||||
|
||||
if (match.params.viewId && match.params.viewId !== Constants.globalTeamId) {
|
||||
dispatch(setCurrentView(match.params.viewId))
|
||||
UserSettings.setLastViewId(match.params.boardId, match.params.viewId)
|
||||
if (viewId && viewId !== Constants.globalTeamId) {
|
||||
dispatch(setCurrentView(viewId))
|
||||
UserSettings.setLastViewId(match.params.boardId, viewId)
|
||||
}
|
||||
|
||||
if (!props.readonly && me) {
|
||||
loadOrJoinBoard(me.id, teamId, match.params.boardId)
|
||||
}
|
||||
}
|
||||
}, [teamId, match.params.boardId, match.params.viewId, me?.id])
|
||||
}, [teamId, match.params.boardId, viewId, me?.id])
|
||||
|
||||
if (props.readonly) {
|
||||
useEffect(() => {
|
||||
@ -137,12 +206,7 @@ const BoardPage = (props: Props): JSX.Element => {
|
||||
<BackwardCompatibilityQueryParamsRedirect/>
|
||||
<SetWindowTitleAndIcon/>
|
||||
<UndoRedoHotKeys/>
|
||||
<WebsocketConnection
|
||||
teamId={teamId}
|
||||
boardId={match.params.boardId}
|
||||
readonly={props.readonly || false}
|
||||
loadAction={loadAction}
|
||||
/>
|
||||
<WebsocketConnection/>
|
||||
<CloudMessage/>
|
||||
|
||||
{!mobileWarningClosed &&
|
||||
@ -170,7 +234,6 @@ const BoardPage = (props: Props): JSX.Element => {
|
||||
</div>}
|
||||
|
||||
{
|
||||
|
||||
// Don't display Templates page
|
||||
// if readonly mode and no board defined.
|
||||
(!props.readonly || activeBoardId !== undefined) &&
|
||||
|
@ -1,90 +1,26 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useEffect, useState, useMemo} from 'react'
|
||||
import {batch} from 'react-redux'
|
||||
import {useLocation} from 'react-router-dom'
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {FormattedMessage} from 'react-intl'
|
||||
|
||||
import {Block} from '../../blocks/block'
|
||||
import {ContentBlock} from '../../blocks/contentBlock'
|
||||
import {CommentBlock} from '../../blocks/commentBlock'
|
||||
import {Board, BoardMember} from '../../blocks/board'
|
||||
import {Card} from '../../blocks/card'
|
||||
import {BoardView} from '../../blocks/boardView'
|
||||
import wsClient, {Subscription, WSClient} from '../../wsclient'
|
||||
import './boardPage.scss'
|
||||
import {updateBoards, updateMembersEnsuringBoardsAndUsers} from '../../store/boards'
|
||||
import {updateViews} from '../../store/views'
|
||||
import {updateCards} from '../../store/cards'
|
||||
import {updateContents} from '../../store/contents'
|
||||
import {updateComments} from '../../store/comments'
|
||||
import {useAppSelector, useAppDispatch} from '../../store/hooks'
|
||||
import wsClient, {WSClient} from '../../wsclient'
|
||||
import {useAppSelector} from '../../store/hooks'
|
||||
|
||||
import {followBlock, getMe, unfollowBlock} from '../../store/users'
|
||||
import {getMe} from '../../store/users'
|
||||
import {IUser} from '../../user'
|
||||
import {Constants} from "../../constants"
|
||||
|
||||
const websocketTimeoutForBanner = 5000
|
||||
|
||||
type Props = {
|
||||
readonly: boolean
|
||||
teamId: string
|
||||
boardId: string
|
||||
loadAction: (boardID: string) => any
|
||||
}
|
||||
|
||||
const WebsocketConnection = (props: Props) => {
|
||||
const dispatch = useAppDispatch()
|
||||
// WebsocketConnection component checks the websockets client for
|
||||
// state changes and if the connection is closed, shows a banner
|
||||
// indicating that there has been a connection error
|
||||
const WebsocketConnection = () => {
|
||||
const [websocketClosed, setWebsocketClosed] = useState(false)
|
||||
const queryString = new URLSearchParams(useLocation().search)
|
||||
const me = useAppSelector<IUser|null>(getMe)
|
||||
|
||||
const token = useMemo(() => {
|
||||
const storedToken = localStorage.getItem('focalboardSessionId') || ''
|
||||
if (props.readonly) {
|
||||
return storedToken || queryString.get('r') || ''
|
||||
}
|
||||
return storedToken
|
||||
}, [props.readonly])
|
||||
|
||||
useEffect(() => {
|
||||
let subscribedToTeam = false
|
||||
if (wsClient.state === 'open') {
|
||||
wsClient.authenticate(props.teamId || Constants.globalTeamId, token)
|
||||
wsClient.subscribeToTeam(props.teamId || Constants.globalTeamId)
|
||||
subscribedToTeam = true
|
||||
}
|
||||
|
||||
const incrementalBlockUpdate = (_: WSClient, blocks: Block[]) => {
|
||||
const teamBlocks = blocks
|
||||
|
||||
batch(() => {
|
||||
dispatch(updateViews(teamBlocks.filter((b: Block) => b.type === 'view' || b.deleteAt !== 0) as BoardView[]))
|
||||
dispatch(updateCards(teamBlocks.filter((b: Block) => b.type === 'card' || b.deleteAt !== 0) as Card[]))
|
||||
dispatch(updateComments(teamBlocks.filter((b: Block) => b.type === 'comment' || b.deleteAt !== 0) as CommentBlock[]))
|
||||
dispatch(updateContents(teamBlocks.filter((b: Block) => b.type !== 'card' && b.type !== 'view' && b.type !== 'board' && b.type !== 'comment') as ContentBlock[]))
|
||||
})
|
||||
}
|
||||
|
||||
const incrementalBoardUpdate = (_: WSClient, boards: Board[]) => {
|
||||
// only takes into account the entities that belong to the team or the user boards
|
||||
const teamBoards = boards.filter((b: Board) => b.teamId === Constants.globalTeamId || b.teamId === props.teamId)
|
||||
dispatch(updateBoards(teamBoards))
|
||||
}
|
||||
|
||||
const incrementalBoardMemberUpdate = (_: WSClient, members: BoardMember[]) => {
|
||||
dispatch(updateMembersEnsuringBoardsAndUsers(members))
|
||||
}
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
const updateWebsocketState = (_: WSClient, newState: 'init'|'open'|'close'): void => {
|
||||
if (newState === 'open') {
|
||||
const newToken = localStorage.getItem('focalboardSessionId') || ''
|
||||
wsClient.authenticate(props.teamId || Constants.globalTeamId, newToken)
|
||||
wsClient.subscribeToTeam(props.teamId || Constants.globalTeamId)
|
||||
subscribedToTeam = true
|
||||
}
|
||||
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
@ -92,42 +28,21 @@ const WebsocketConnection = (props: Props) => {
|
||||
if (newState === 'close') {
|
||||
timeout = setTimeout(() => {
|
||||
setWebsocketClosed(true)
|
||||
subscribedToTeam = false
|
||||
}, websocketTimeoutForBanner)
|
||||
} else {
|
||||
setWebsocketClosed(false)
|
||||
}
|
||||
}
|
||||
|
||||
wsClient.addOnChange(incrementalBlockUpdate, 'block')
|
||||
wsClient.addOnChange(incrementalBoardUpdate, 'board')
|
||||
wsClient.addOnChange(incrementalBoardMemberUpdate, 'boardMembers')
|
||||
wsClient.addOnReconnect(() => dispatch(props.loadAction(props.boardId)))
|
||||
wsClient.addOnStateChange(updateWebsocketState)
|
||||
wsClient.setOnFollowBlock((_: WSClient, subscription: Subscription): void => {
|
||||
if (subscription.subscriberId === me?.id) {
|
||||
dispatch(followBlock(subscription))
|
||||
}
|
||||
})
|
||||
wsClient.setOnUnfollowBlock((_: WSClient, subscription: Subscription): void => {
|
||||
if (subscription.subscriberId === me?.id) {
|
||||
dispatch(unfollowBlock(subscription))
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (subscribedToTeam) {
|
||||
// wsClient.unsubscribeToTeam(props.teamId || '0')
|
||||
}
|
||||
wsClient.removeOnChange(incrementalBlockUpdate, 'block')
|
||||
wsClient.removeOnChange(incrementalBoardUpdate, 'board')
|
||||
wsClient.removeOnChange(incrementalBoardMemberUpdate, 'boardMembers')
|
||||
wsClient.removeOnReconnect(() => dispatch(props.loadAction(props.boardId)))
|
||||
wsClient.removeOnStateChange(updateWebsocketState)
|
||||
}
|
||||
}, [me?.id, props.teamId, props.readonly, props.boardId, props.loadAction])
|
||||
}, [me?.id])
|
||||
|
||||
if (websocketClosed) {
|
||||
return (
|
||||
|
@ -7,7 +7,7 @@ import {default as client} from '../octoClient'
|
||||
import {Board, BoardMember} from '../blocks/board'
|
||||
import {IUser} from '../user'
|
||||
|
||||
import {initialLoad, initialReadOnlyLoad, loadBoardData} from './initialLoad'
|
||||
import {initialLoad, initialReadOnlyLoad, loadBoardData, loadBoards} from './initialLoad'
|
||||
|
||||
import {addBoardUsers, removeBoardUsersById, setBoardUsers} from './users'
|
||||
|
||||
@ -95,7 +95,7 @@ export const updateMembersEnsuringBoardsAndUsers = createAsyncThunk(
|
||||
},
|
||||
)
|
||||
|
||||
export const updateMembers = (state: BoardsState, action: PayloadAction<BoardMember[]>) => {
|
||||
export const updateMembersHandler = (state: BoardsState, action: PayloadAction<BoardMember[]>) => {
|
||||
if (action.payload.length === 0) {
|
||||
return
|
||||
}
|
||||
@ -140,7 +140,7 @@ const boardsSlice = createSlice({
|
||||
}
|
||||
}
|
||||
},
|
||||
updateMembers,
|
||||
updateMembers: updateMembersHandler,
|
||||
},
|
||||
|
||||
extraReducers: (builder) => {
|
||||
@ -178,6 +178,12 @@ const boardsSlice = createSlice({
|
||||
state.myBoardMemberships[boardMember.boardId] = boardMember
|
||||
})
|
||||
})
|
||||
builder.addCase(loadBoards.fulfilled, (state, action) => {
|
||||
state.boards = {}
|
||||
action.payload.boards.forEach((board) => {
|
||||
state.boards[board.id] = board
|
||||
})
|
||||
})
|
||||
builder.addCase(fetchBoardMembers.fulfilled, (state, action) => {
|
||||
if (action.payload.length === 0) {
|
||||
return
|
||||
@ -192,11 +198,11 @@ const boardsSlice = createSlice({
|
||||
}, {})
|
||||
state.membersInBoards[boardId] = boardMembersMap
|
||||
})
|
||||
builder.addCase(updateMembersEnsuringBoardsAndUsers.fulfilled, updateMembers)
|
||||
builder.addCase(updateMembersEnsuringBoardsAndUsers.fulfilled, updateMembersHandler)
|
||||
},
|
||||
})
|
||||
|
||||
export const {updateBoards, setCurrent, setLinkToChannel} = boardsSlice.actions
|
||||
export const {updateBoards, setCurrent, setLinkToChannel, updateMembers} = boardsSlice.actions
|
||||
export const {reducer} = boardsSlice
|
||||
|
||||
export const getBoards = (state: RootState): {[key: string]: Board} => state.boards?.boards || {}
|
||||
|
@ -69,6 +69,16 @@ export const loadBoardData = createAsyncThunk(
|
||||
},
|
||||
)
|
||||
|
||||
export const loadBoards = createAsyncThunk(
|
||||
'loadBoards',
|
||||
async () => {
|
||||
const boards = await client.getBoards()
|
||||
return {
|
||||
boards,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const getUserBlockSubscriptions = (state: RootState): Array<Subscription> => state.users.blockSubscriptions
|
||||
|
||||
export const getUserBlockSubscriptionList = createSelector(
|
||||
|
@ -35,6 +35,7 @@ export const refreshCurrentTeam = createAsyncThunk(
|
||||
)
|
||||
|
||||
type TeamState = {
|
||||
currentId: string
|
||||
current: Team | null
|
||||
allTeams: Array<Team>
|
||||
}
|
||||
@ -43,11 +44,13 @@ const teamSlice = createSlice({
|
||||
name: 'teams',
|
||||
initialState: {
|
||||
current: null,
|
||||
currentId: '',
|
||||
allTeams: [],
|
||||
} as TeamState,
|
||||
reducers: {
|
||||
setTeam: (state, action: PayloadAction<string>) => {
|
||||
const teamID = action.payload
|
||||
state.currentId = teamID
|
||||
const team = state.allTeams.find((t) => t.id === teamID)
|
||||
if (!team) {
|
||||
Utils.log(`Unable to find team in store. TeamID: ${teamID}`)
|
||||
@ -80,6 +83,7 @@ const teamSlice = createSlice({
|
||||
export const {setTeam} = teamSlice.actions
|
||||
export const {reducer} = teamSlice
|
||||
|
||||
export const getCurrentTeamId = (state: RootState): string => state.teams.currentId
|
||||
export const getCurrentTeam = (state: RootState): Team|null => state.teams.current
|
||||
export const getFirstTeam = (state: RootState): Team|null => state.teams.allTeams[0]
|
||||
export const getAllTeams = (state: RootState): Array<Team> => state.teams.allTeams
|
||||
|
@ -97,10 +97,15 @@ type ChangeHandlers = {
|
||||
BoardMember: OnChangeHandler[]
|
||||
}
|
||||
|
||||
type Subscriptions = {
|
||||
Teams: Record<string, number>
|
||||
}
|
||||
|
||||
class WSClient {
|
||||
ws: WebSocket|null = null
|
||||
client: MMWebSocketClient|null = null
|
||||
onPluginReconnect: null|(() => void) = null
|
||||
token = ''
|
||||
pluginId = ''
|
||||
pluginVersion = ''
|
||||
teamId = ''
|
||||
@ -121,6 +126,7 @@ class WSClient {
|
||||
private updatedData: UpdatedData = {Blocks: [], Categories: [], BoardCategories: [], Boards: [], BoardMembers: []}
|
||||
private updateTimeout?: NodeJS.Timeout
|
||||
private errorPollId?: NodeJS.Timeout
|
||||
private subscriptions: Subscriptions = {Teams: {}}
|
||||
|
||||
private logged = false
|
||||
|
||||
@ -154,6 +160,17 @@ class WSClient {
|
||||
Utils.log(`WSClient initialised for plugin id "${pluginId}"`)
|
||||
}
|
||||
|
||||
resetSubscriptions() {
|
||||
this.subscriptions = {Teams: {}} as Subscriptions
|
||||
}
|
||||
|
||||
// this function sends the necessary commands for the connection
|
||||
// to subscribe to all registered subscriptions
|
||||
subscribe() {
|
||||
Utils.log('Sending commands for the registered subscriptions')
|
||||
Object.keys(this.subscriptions.Teams).forEach(teamId => this.sendSubscribeToTeamCommand(teamId))
|
||||
}
|
||||
|
||||
sendCommand(command: WSCommand): void {
|
||||
if (this.client !== null) {
|
||||
const {action, ...data} = command
|
||||
@ -164,6 +181,30 @@ class WSClient {
|
||||
this.ws?.send(JSON.stringify(command))
|
||||
}
|
||||
|
||||
sendAuthenticationCommand(token: string): void {
|
||||
const command = { action: ACTION_AUTH, token }
|
||||
|
||||
this.sendCommand(command)
|
||||
}
|
||||
|
||||
sendSubscribeToTeamCommand(teamId: string): void {
|
||||
const command: WSCommand = {
|
||||
action: ACTION_SUBSCRIBE_TEAM,
|
||||
teamId,
|
||||
}
|
||||
|
||||
this.sendCommand(command)
|
||||
}
|
||||
|
||||
sendUnsubscribeToTeamCommand(teamId: string): void {
|
||||
const command: WSCommand = {
|
||||
action: ACTION_UNSUBSCRIBE_TEAM,
|
||||
teamId,
|
||||
}
|
||||
|
||||
this.sendCommand(command)
|
||||
}
|
||||
|
||||
addOnChange(handler: OnChangeHandler, type: ChangeHandlerType): void {
|
||||
switch (type) {
|
||||
case 'block':
|
||||
@ -275,6 +316,10 @@ class WSClient {
|
||||
const onConnect = () => {
|
||||
Utils.log('WSClient in plugin mode, reusing Mattermost WS connection')
|
||||
|
||||
// if there are any subscriptions set by the
|
||||
// components, send their subscribe messages
|
||||
this.subscribe()
|
||||
|
||||
for (const handler of this.onStateChange) {
|
||||
handler(this, 'open')
|
||||
}
|
||||
@ -341,6 +386,16 @@ class WSClient {
|
||||
ws.onopen = () => {
|
||||
Utils.log('WSClient webSocket opened.')
|
||||
this.state = 'open'
|
||||
|
||||
// if has a token defined when connecting, authenticate
|
||||
if (this.token) {
|
||||
this.sendAuthenticationCommand(this.token)
|
||||
}
|
||||
|
||||
// if there are any subscriptions set by the components,
|
||||
// send their subscribe messages
|
||||
this.subscribe()
|
||||
|
||||
for (const handler of this.onStateChange) {
|
||||
handler(this, 'open')
|
||||
}
|
||||
@ -363,6 +418,8 @@ class WSClient {
|
||||
}
|
||||
this.state = 'close'
|
||||
setTimeout(() => {
|
||||
// ToDo: assert that this actually runs the onopen
|
||||
// contents (auth + this.subscribe())
|
||||
this.open()
|
||||
for (const handler of this.onReconnect) {
|
||||
handler(this)
|
||||
@ -498,22 +555,55 @@ class WSClient {
|
||||
}
|
||||
}
|
||||
|
||||
authenticate(teamId: string, token: string): void {
|
||||
if (!this.hasConn()) {
|
||||
Utils.assertFailure('WSClient.addBlocks: ws is not open')
|
||||
return
|
||||
}
|
||||
|
||||
authenticate(token: string): void {
|
||||
if (!token) {
|
||||
Utils.assertFailure('WSClient trying to authenticate without a token')
|
||||
return
|
||||
}
|
||||
const command = {
|
||||
action: ACTION_AUTH,
|
||||
token,
|
||||
teamId,
|
||||
|
||||
if (this.hasConn()) {
|
||||
this.sendAuthenticationCommand(token)
|
||||
}
|
||||
|
||||
this.sendCommand(command)
|
||||
this.token = token
|
||||
}
|
||||
|
||||
subscribeToTeam(teamId: string): void {
|
||||
if (!this.subscriptions.Teams[teamId]) {
|
||||
Utils.log(`First component subscribing to team ${teamId}`)
|
||||
// only send command if the WS connection has already been
|
||||
// stablished. If not, the connect or reconnect functions
|
||||
// will do
|
||||
if (this.hasConn()) {
|
||||
this.sendSubscribeToTeamCommand(teamId)
|
||||
}
|
||||
|
||||
this.teamId = teamId
|
||||
this.subscriptions.Teams[teamId] = 1
|
||||
return
|
||||
}
|
||||
|
||||
this.subscriptions.Teams[teamId] += 1
|
||||
}
|
||||
|
||||
unsubscribeToTeam(teamId: string): void {
|
||||
if (!this.subscriptions.Teams[teamId]) {
|
||||
Utils.logError('Component trying to unsubscribe to a team when no subscriptions are registered. Doing nothing')
|
||||
return
|
||||
}
|
||||
|
||||
this.subscriptions.Teams[teamId] -= 1
|
||||
if (this.subscriptions.Teams[teamId] === 0) {
|
||||
Utils.log(`Last subscription to team ${teamId} being removed`)
|
||||
if (this.hasConn()) {
|
||||
this.sendUnsubscribeToTeamCommand(teamId)
|
||||
}
|
||||
|
||||
if (teamId == this.teamId) {
|
||||
this.teamId = ''
|
||||
}
|
||||
delete this.subscriptions.Teams[teamId]
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToBlocks(teamId: string, blockIds: string[], readToken = ''): void {
|
||||
@ -532,34 +622,6 @@ class WSClient {
|
||||
this.sendCommand(command)
|
||||
}
|
||||
|
||||
unsubscribeToTeam(teamId: string): void {
|
||||
if (!this.hasConn()) {
|
||||
Utils.assertFailure('WSClient.subscribeToTeam: ws is not open')
|
||||
return
|
||||
}
|
||||
|
||||
const command: WSCommand = {
|
||||
action: ACTION_UNSUBSCRIBE_TEAM,
|
||||
teamId,
|
||||
}
|
||||
|
||||
this.sendCommand(command)
|
||||
}
|
||||
|
||||
subscribeToTeam(teamId: string): void {
|
||||
if (!this.hasConn()) {
|
||||
Utils.assertFailure('WSClient.subscribeToTeam: ws is not open')
|
||||
return
|
||||
}
|
||||
|
||||
const command: WSCommand = {
|
||||
action: ACTION_SUBSCRIBE_TEAM,
|
||||
teamId,
|
||||
}
|
||||
|
||||
this.sendCommand(command)
|
||||
}
|
||||
|
||||
unsubscribeFromBlocks(teamId: string, blockIds: string[], readToken = ''): void {
|
||||
if (!this.hasConn()) {
|
||||
Utils.assertFailure('WSClient.removeBlocks: ws is not open')
|
||||
@ -609,21 +671,6 @@ class WSClient {
|
||||
}, this.notificationDelay)
|
||||
}
|
||||
|
||||
// private queueUpdateBoardNotification(board: Board) {
|
||||
// this.updatedBoards = this.updatedBoards.filter((o) => o.id !== board.id) // Remove existing queued update
|
||||
// // ToDo: hydrate required?
|
||||
// // this.updatedBoards.push(OctoUtils.hydrateBoard(board))
|
||||
// this.updatedBoards.push(board)
|
||||
// if (this.updateTimeout) {
|
||||
// clearTimeout(this.updateTimeout)
|
||||
// this.updateTimeout = undefined
|
||||
// }
|
||||
//
|
||||
// this.updateTimeout = setTimeout(() => {
|
||||
// this.flushUpdateNotifications()
|
||||
// }, this.notificationDelay)
|
||||
// }
|
||||
|
||||
private logUpdateNotification() {
|
||||
for (const block of this.updatedData.Blocks) {
|
||||
Utils.log(`WSClient flush update block: ${block.id}`)
|
||||
|
Loading…
x
Reference in New Issue
Block a user