1
0
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:
Miguel de la Cruz 2022-07-14 12:31:51 +02:00 committed by GitHub
parent 4561d2c787
commit 3ae821d2e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 780 additions and 420 deletions

View File

@ -34,6 +34,7 @@ describe('components/boardSelector', () => {
teams: { teams: {
allTeams: [team], allTeams: [team],
current: team, current: team,
currentId: team.id,
}, },
language: { language: {
value: 'en', value: 'en',

View File

@ -7,9 +7,11 @@ import debounce from 'lodash/debounce'
import {getMessages} from '../../../../webapp/src/i18n' import {getMessages} from '../../../../webapp/src/i18n'
import {getLanguage} from '../../../../webapp/src/store/language' import {getLanguage} from '../../../../webapp/src/store/language'
import {useWebsockets} from '../../../../webapp/src/hooks/websockets'
import octoClient from '../../../../webapp/src/octoClient' import octoClient from '../../../../webapp/src/octoClient'
import mutator from '../../../../webapp/src/mutator' 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 {createBoard, BoardsAndBlocks, Board} from '../../../../webapp/src/blocks/board'
import {createBoardView} from '../../../../webapp/src/blocks/boardView' import {createBoardView} from '../../../../webapp/src/blocks/boardView'
import {useAppSelector, useAppDispatch} from '../../../../webapp/src/store/hooks' 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 Button from '../../../../webapp/src/widgets/buttons/button'
import {getCurrentLinkToChannel, setLinkToChannel} from '../../../../webapp/src/store/boards' import {getCurrentLinkToChannel, setLinkToChannel} from '../../../../webapp/src/store/boards'
import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../../../webapp/src/telemetry/telemetryClient' import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../../../webapp/src/telemetry/telemetryClient'
import {WSClient} from '../../../../webapp/src/wsclient'
import BoardSelectorItem from './boardSelectorItem' import BoardSelectorItem from './boardSelectorItem'
@ -31,7 +34,7 @@ const BoardSelector = () => {
teamsById[t.id] = t teamsById[t.id] = t
}) })
const intl = useIntl() const intl = useIntl()
const team = useAppSelector(getCurrentTeam) const teamId = useAppSelector(getCurrentTeamId)
const currentChannel = useAppSelector(getCurrentLinkToChannel) const currentChannel = useAppSelector(getCurrentLinkToChannel)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@ -43,20 +46,45 @@ const BoardSelector = () => {
const searchHandler = useCallback(async (query: string): Promise<void> => { const searchHandler = useCallback(async (query: string): Promise<void> => {
setSearchQuery(query) setSearchQuery(query)
if (query.trim().length === 0 || !team) { if (query.trim().length === 0 || !teamId) {
return return
} }
const items = await octoClient.search(team.id, query) const items = await octoClient.search(teamId, query)
setResults(items) setResults(items)
setIsSearching(false) setIsSearching(false)
}, [team?.id]) }, [teamId])
const debouncedSearchHandler = useMemo(() => debounce(searchHandler, 200), [searchHandler]) const debouncedSearchHandler = useMemo(() => debounce(searchHandler, 200), [searchHandler])
const emptyResult = results.length === 0 && !isSearching && searchQuery 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 return null
} }
if (!currentChannel) { if (!currentChannel) {
@ -68,34 +96,18 @@ const BoardSelector = () => {
setShowLinkBoardConfirmation(board) setShowLinkBoardConfirmation(board)
return return
} }
const newBoard = createBoard(board) const newBoard = createBoard({...board, channelId: currentChannel})
newBoard.channelId = currentChannel
await mutator.updateBoard(newBoard, board, 'linked channel') await mutator.updateBoard(newBoard, board, 'linked channel')
for (const result of results) {
if (result.id == board.id) {
result.channelId = currentChannel
setResults([...results])
}
}
setShowLinkBoardConfirmation(null) setShowLinkBoardConfirmation(null)
} }
const unlinkBoard = async (board: Board): Promise<void> => { const unlinkBoard = async (board: Board): Promise<void> => {
const newBoard = createBoard(board) const newBoard = createBoard({...board, channelId: ''})
newBoard.channelId = ''
await mutator.updateBoard(newBoard, board, 'unlinked channel') 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 newLinkedBoard = async (): Promise<void> => {
const board = createBoard() const board = {...createBoard(), teamId, channelId: currentChannel}
board.teamId = team.id
board.channelId = currentChannel
const view = createBoardView() const view = createBoardView()
view.fields.viewType = 'board' view.fields.viewType = 'board'
@ -111,7 +123,7 @@ const BoardSelector = () => {
const newBoard = bab.boards[0] const newBoard = bab.boards[0]
// TODO: Maybe create a new event for create linked board // TODO: Maybe create a new event for create linked board
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoard, {board: newBoard?.id}) 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('')) dispatch(setLinkToChannel(''))
}, },
async () => {return}, async () => {return},
@ -122,7 +134,13 @@ const BoardSelector = () => {
<div className='focalboard-body'> <div className='focalboard-body'>
<Dialog <Dialog
className='BoardSelector' className='BoardSelector'
onClose={() => dispatch(setLinkToChannel(''))} onClose={() => {
dispatch(setLinkToChannel(''))
setResults([])
setIsSearching(false)
setSearchQuery('')
setShowLinkBoardConfirmation(null)
}}
> >
{showLinkBoardConfirmation && {showLinkBoardConfirmation &&
<ConfirmationDialog <ConfirmationDialog

View File

@ -29,6 +29,11 @@ exports[`components/boardsUnfurl/BoardsUnfurl renders normally 1`] = `
</span> </span>
</div> </div>
</div> </div>
<div
class="body"
>
<div />
</div>
<div <div
class="footer" class="footer"
> >
@ -46,8 +51,7 @@ exports[`components/boardsUnfurl/BoardsUnfurl renders normally 1`] = `
<span <span
class="post-preview__time" class="post-preview__time"
> >
Updated Updated January 01, 1970, 12:00 AM
</span> </span>
</div> </div>
</div> </div>

View File

@ -10,6 +10,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import {mocked} from 'jest-mock' import {mocked} from 'jest-mock'
import {Utils} from '../../../../../webapp/src/utils'
import {createCard} from '../../../../../webapp/src/blocks/card' import {createCard} from '../../../../../webapp/src/blocks/card'
import {createBoard} from '../../../../../webapp/src/blocks/board' import {createBoard} from '../../../../../webapp/src/blocks/board'
import octoClient from '../../../../../webapp/src/octoClient' import octoClient from '../../../../../webapp/src/octoClient'
@ -18,29 +19,39 @@ import {wrapIntl} from '../../../../../webapp/src/testUtils'
import BoardsUnfurl from './boardsUnfurl' import BoardsUnfurl from './boardsUnfurl'
jest.mock('../../../../../webapp/src/octoClient') jest.mock('../../../../../webapp/src/octoClient')
jest.mock('../../../../../webapp/src/utils')
const mockedOctoClient = mocked(octoClient, true) 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', () => { describe('components/boardsUnfurl/BoardsUnfurl', () => {
const team = {
id: 'team-id',
name: 'team',
display_name: 'Team name',
}
beforeEach(() => { beforeEach(() => {
// This is done to the websocket not to try to connect directly
mockedUtils.isFocalboardPlugin.mockReturnValue(true)
jest.clearAllMocks() jest.clearAllMocks()
}) })
it('renders normally', async () => { it('renders normally', async () => {
const mockStore = configureStore([]) const mockStore = configureStore([])
const store = mockStore({ const store = mockStore({
entities: { language: {
users: { value: 'en',
currentUserId: 'id_1', },
profiles: { teams: {
id_1: { allTeams: [team],
locale: 'en', current: team,
},
},
},
}, },
}) })
const cards = [{...createCard(), title: 'test card'}] const cards = [{...createCard(), title: 'test card', updateAt: 12345}]
const board = {...createBoard(), title: 'test board'} const board = {...createBoard(), title: 'test board'}
mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards) mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards)
@ -50,7 +61,7 @@ describe('components/boardsUnfurl/BoardsUnfurl', () => {
<ReduxProvider store={store}> <ReduxProvider store={store}>
{wrapIntl( {wrapIntl(
<BoardsUnfurl <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> </ReduxProvider>
@ -62,6 +73,8 @@ describe('components/boardsUnfurl/BoardsUnfurl', () => {
const result = render(component) const result = render(component)
container = result.container container = result.container
}) })
expect(mockedOctoClient.getBoard).toBeCalledWith(board.id)
expect(mockedOctoClient.getBlocksWithBlockID).toBeCalledWith(cards[0].id, board.id, "abc")
expect(container).toMatchSnapshot() expect(container).toMatchSnapshot()
}) })
@ -69,19 +82,16 @@ describe('components/boardsUnfurl/BoardsUnfurl', () => {
it('renders when limited', async () => { it('renders when limited', async () => {
const mockStore = configureStore([]) const mockStore = configureStore([])
const store = mockStore({ const store = mockStore({
entities: { language: {
users: { value: 'en',
currentUserId: 'id_1', },
profiles: { teams: {
id_1: { allTeams: [team],
locale: 'en', 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'} const board = {...createBoard(), title: 'test board'}
mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards) mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards)
@ -91,7 +101,7 @@ describe('components/boardsUnfurl/BoardsUnfurl', () => {
<ReduxProvider store={store}> <ReduxProvider store={store}>
{wrapIntl( {wrapIntl(
<BoardsUnfurl <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> </ReduxProvider>

View File

@ -1,22 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react' import React, {useState, useEffect} from 'react'
import {IntlProvider, FormattedMessage} from 'react-intl' import {IntlProvider, FormattedMessage, useIntl} from 'react-intl'
import {connect} from 'react-redux'
import {GlobalState} from 'mattermost-redux/types/store' import WithWebSockets from '../../../../../webapp/src/components/withWebSockets'
import {getCurrentUserLocale} from 'mattermost-redux/selectors/entities/i18n' 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 {getMessages} from './../../../../../webapp/src/i18n'
import {Utils} from './../../../../../webapp/src/utils' import {Utils} from './../../../../../webapp/src/utils'
import {Block} from './../../../../../webapp/src/blocks/block'
import {Card} from './../../../../../webapp/src/blocks/card' import {Card} from './../../../../../webapp/src/blocks/card'
import {Board} from './../../../../../webapp/src/blocks/board' import {Board} from './../../../../../webapp/src/blocks/board'
import {ContentBlock} from './../../../../../webapp/src/blocks/contentBlock' import {ContentBlock} from './../../../../../webapp/src/blocks/contentBlock'
import octoClient from './../../../../../webapp/src/octoClient' import octoClient from './../../../../../webapp/src/octoClient'
const noop = () => '' const noop = () => ''
const Avatar = (window as any).Components?.Avatar || 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 const imageURLForUser = (window as any).Components?.imageURLForUser || noop
import './boardsUnfurl.scss' import './boardsUnfurl.scss'
@ -26,15 +33,7 @@ type Props = {
embed: { embed: {
data: string, data: string,
}, },
locale: string, webSocketClient?: MMWebSocketClient,
}
function mapStateToProps(state: GlobalState) {
const locale = getCurrentUserLocale(state)
return {
locale,
}
} }
class FocalboardEmbeddedData { class FocalboardEmbeddedData {
@ -59,8 +58,11 @@ export const BoardsUnfurl = (props: Props): JSX.Element => {
return <></> return <></>
} }
const {embed, locale} = props const intl = useIntl()
const {embed, webSocketClient} = props
const focalboardInformation: FocalboardEmbeddedData = new FocalboardEmbeddedData(embed.data) const focalboardInformation: FocalboardEmbeddedData = new FocalboardEmbeddedData(embed.data)
const currentTeamId = useAppSelector(getCurrentTeamId)
const {teamID, cardID, boardID, readToken, originalPath} = focalboardInformation const {teamID, cardID, boardID, readToken, originalPath} = focalboardInformation
const baseURL = window.location.origin const baseURL = window.location.origin
@ -111,6 +113,26 @@ export const BoardsUnfurl = (props: Props): JSX.Element => {
fetchData() fetchData()
}, [originalPath]) }, [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 remainder = 0
let html = '' let html = ''
const propertiesToDisplay: Array<Record<string, string>> = [] const propertiesToDisplay: Array<Record<string, string>> = []
@ -160,10 +182,7 @@ export const BoardsUnfurl = (props: Props): JSX.Element => {
} }
return ( return (
<IntlProvider <WithWebSockets manifest={manifest} webSocketClient={webSocketClient}>
messages={getMessages(locale)}
locale={locale}
>
{!loading && (!card || !board) && <></>} {!loading && (!card || !board) && <></>}
{!loading && card && board && {!loading && card && board &&
<a <a
@ -192,77 +211,78 @@ export const BoardsUnfurl = (props: Props): JSX.Element => {
} }
{card.limited && {card.limited &&
<p className='limited'> <p className='limited'>
<FormattedMessage <FormattedMessage
id='BoardsUnfurl.Limited' id='BoardsUnfurl.Limited'
defaultMessage={'Additional details are hidden due to the card being archived'} defaultMessage={'Additional details are hidden due to the card being archived'}
/> />
</p>} </p>}
{/* Footer of the Card*/} {/* Footer of the Card*/}
{!card.limited && {!card.limited &&
<div className='footer'> <div className='footer'>
<div className='avatar'> <div className='avatar'>
<Avatar <Avatar
size={'md'} size={'md'}
url={imageURLForUser(card.createdBy)} url={imageURLForUser(card.createdBy)}
className={'avatar-post-preview'} 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'}
/>
),
}}
/> />
</span> </div>
</div> <div className='timestamp_properties'>
</div>} <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> </a>
} }
{loading && {loading &&
<div style={{height: '302px'}}/> <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> </IntlProvider>
) )
} }
export default connect(mapStateToProps)(BoardsUnfurl) export default IntlBoardsUnfurl

View File

@ -4,12 +4,18 @@
import React from 'react' import React from 'react'
import {Provider as ReduxProvider} from 'react-redux' import {Provider as ReduxProvider} from 'react-redux'
import {render} from '@testing-library/react' 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 {createBoard} from '../../../../webapp/src/blocks/board'
import {mockStateStore, wrapIntl} from '../../../../webapp/src/testUtils' import {mockStateStore, wrapIntl} from '../../../../webapp/src/testUtils'
import RHSChannelBoards from './rhsChannelBoards' import RHSChannelBoards from './rhsChannelBoards'
jest.mock('../../../../webapp/src/octoClient')
const mockedOctoClient = mocked(octoClient, true)
describe('components/rhsChannelBoards', () => { describe('components/rhsChannelBoards', () => {
const board1 = createBoard() const board1 = createBoard()
board1.updateAt = 1657311058157 board1.updateAt = 1657311058157
@ -29,6 +35,7 @@ describe('components/rhsChannelBoards', () => {
teams: { teams: {
allTeams: [team], allTeams: [team],
current: team, current: team,
currentId: team.id,
}, },
language: { language: {
value: 'en', 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 () => { it('renders the RHS for channel boards', async () => {
const store = mockStateStore([], state) const store = mockStateStore([thunk], state)
const {container} = render(wrapIntl( const {container} = render(wrapIntl(
<ReduxProvider store={store}> <ReduxProvider store={store}>
<RHSChannelBoards/> <RHSChannelBoards/>
@ -67,7 +79,7 @@ describe('components/rhsChannelBoards', () => {
it('renders with empty list of boards', async () => { it('renders with empty list of boards', async () => {
const localState = {...state, boards: {...state.boards, boards: {}}} const localState = {...state, boards: {...state.boards, boards: {}}}
const store = mockStateStore([], localState) const store = mockStateStore([thunk], localState)
const {container} = render(wrapIntl( const {container} = render(wrapIntl(
<ReduxProvider store={store}> <ReduxProvider store={store}>
<RHSChannelBoards/> <RHSChannelBoards/>

View File

@ -1,18 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react' import React, {useEffect} from 'react'
import {FormattedMessage, IntlProvider} from 'react-intl' import {FormattedMessage, IntlProvider} from 'react-intl'
import {getMessages} from '../../../../webapp/src/i18n' import {getMessages} from '../../../../webapp/src/i18n'
import {getLanguage} from '../../../../webapp/src/store/language' 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 {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 {useAppSelector, useAppDispatch} from '../../../../webapp/src/store/hooks'
import AddIcon from '../../../../webapp/src/widgets/icons/add' import AddIcon from '../../../../webapp/src/widgets/icons/add'
import Button from '../../../../webapp/src/widgets/buttons/button' import Button from '../../../../webapp/src/widgets/buttons/button'
import {WSClient} from '../../../../webapp/src/wsclient'
import RHSChannelBoardItem from './rhsChannelBoardItem' import RHSChannelBoardItem from './rhsChannelBoardItem'
import './rhsChannelBoards.scss' import './rhsChannelBoards.scss'
@ -21,14 +27,35 @@ const boardsScreenshots = (window as any).baseURL + '/public/boards-screenshots.
const RHSChannelBoards = () => { const RHSChannelBoards = () => {
const boards = useAppSelector(getMySortedBoards) const boards = useAppSelector(getMySortedBoards)
const team = useAppSelector(getCurrentTeam) const teamId = useAppSelector(getCurrentTeamId)
const currentChannel = useAppSelector(getCurrentChannel) const currentChannel = useAppSelector(getCurrentChannel)
const dispatch = useAppDispatch() 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) { if (!boards) {
return null return null
} }
if (!team) { if (!teamId) {
return null return null
} }
if (!currentChannel) { if (!currentChannel) {

View File

@ -23,6 +23,7 @@ windowAny.isFocalboardPlugin = true
import App from '../../../webapp/src/app' import App from '../../../webapp/src/app'
import store from '../../../webapp/src/store' import store from '../../../webapp/src/store'
import {setTeam} from '../../../webapp/src/store/teams' import {setTeam} from '../../../webapp/src/store/teams'
import WithWebSockets from '../../../webapp/src/components/withWebSockets'
import {setChannel} from '../../../webapp/src/store/channels' import {setChannel} from '../../../webapp/src/store/channels'
import {initialLoad} from '../../../webapp/src/store/initialLoad' import {initialLoad} from '../../../webapp/src/store/initialLoad'
import {Utils} from '../../../webapp/src/utils' import {Utils} from '../../../webapp/src/utils'
@ -129,8 +130,6 @@ function customHistory() {
let browserHistory: History<unknown> let browserHistory: History<unknown>
const MainApp = (props: Props) => { const MainApp = (props: Props) => {
wsClient.initPlugin(manifest.id, manifest.version, props.webSocketClient)
useEffect(() => { useEffect(() => {
document.body.classList.add('focalboard-body') document.body.classList.add('focalboard-body')
document.body.classList.add('app__body') document.body.classList.add('app__body')
@ -151,10 +150,12 @@ const MainApp = (props: Props) => {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ReduxProvider store={store}> <ReduxProvider store={store}>
<div id='focalboard-app'> <WithWebSockets manifest={manifest} webSocketClient={props.webSocketClient}>
<App history={browserHistory}/> <div id='focalboard-app'>
</div> <App history={browserHistory}/>
<div id='focalboard-root-portal'/> </div>
<div id='focalboard-root-portal'/>
</WithWebSockets>
</ReduxProvider> </ReduxProvider>
</ErrorBoundary> </ErrorBoundary>
) )
@ -187,6 +188,36 @@ export default class Plugin {
UserSettings.nameFormat = mmStore.getState().entities.preferences?.myPreferences['display_settings--name_format']?.value || null UserSettings.nameFormat = mmStore.getState().entities.preferences?.myPreferences['display_settings--name_format']?.value || null
let theme = mmStore.getState().entities.preferences.myPreferences.theme let theme = mmStore.getState().entities.preferences.myPreferences.theme
setMattermostTheme(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 lastViewedChannel = mmStore.getState().entities.channels.currentChannelId
let prevTeamID: string let prevTeamID: string
@ -210,10 +241,11 @@ export default class Plugin {
if (currentTeamID && currentTeamID !== prevTeamID) { if (currentTeamID && currentTeamID !== prevTeamID) {
if (prevTeamID && window.location.pathname.startsWith(windowAny.frontendBaseURL || '')) { if (prevTeamID && window.location.pathname.startsWith(windowAny.frontendBaseURL || '')) {
browserHistory.push(`/team/${currentTeamID}`) browserHistory.push(`/team/${currentTeamID}`)
wsClient.subscribeToTeam(currentTeamID)
} }
prevTeamID = currentTeamID prevTeamID = currentTeamID
store.dispatch(setTeam(currentTeamID)) store.dispatch(setTeam(currentTeamID))
octoClient.teamId = currentTeamID
store.dispatch(initialLoad())
} }
}) })
@ -221,9 +253,11 @@ export default class Plugin {
windowAny.frontendBaseURL = subpath + '/boards' windowAny.frontendBaseURL = subpath + '/boards'
const {rhsId, toggleRHSPlugin} = this.registry.registerRightHandSidebarComponent( const {rhsId, toggleRHSPlugin} = this.registry.registerRightHandSidebarComponent(
() => ( (props: {webSocketClient: MMWebSocketClient}) => (
<ReduxProvider store={store}> <ReduxProvider store={store}>
<RHSChannelBoards/> <WithWebSockets manifest={manifest} webSocketClient={props.webSocketClient}>
<RHSChannelBoards/>
</WithWebSockets>
</ReduxProvider> </ReduxProvider>
), ),
<ErrorBoundary> <ErrorBoundary>
@ -263,12 +297,25 @@ export default class Plugin {
this.registry.registerAppBarComponent(appBarIconURL, () => mmStore.dispatch(toggleRHSPlugin), 'Boards') 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}> <ReduxProvider store={store}>
<BoardSelector/> <WithWebSockets manifest={manifest} webSocketClient={props.webSocketClient}>
<BoardSelector/>
</WithWebSockets>
</ReduxProvider> </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) => { windowAny.setTeamInSidebar = (teamID: string) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
@ -345,7 +364,6 @@ export default class Plugin {
// @ts-ignore // @ts-ignore
return mmStore.getState().entities.teams.currentTeamId return mmStore.getState().entities.teams.currentTeamId
} }
store.dispatch(initialLoad())
} }
uninitialize(): void { uninitialize(): void {

View File

@ -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}/boards/search", a.sessionRequired(a.handleSearchBoards)).Methods("GET")
apiv2.HandleFunc("/teams/{teamID}/templates", a.sessionRequired(a.handleGetTemplates)).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", 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.attachSession(a.handleGetBoard, false)).Methods("GET")
apiv2.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handlePatchBoard)).Methods("PATCH") apiv2.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handlePatchBoard)).Methods("PATCH")
apiv2.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handleDeleteBoard)).Methods("DELETE") 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) { func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /teams/{teamID}/boards/search searchBoards // 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: // produces:
@ -3415,7 +3416,7 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
auditRec.AddMeta("teamID", teamID) auditRec.AddMeta("teamID", teamID)
// retrieve boards list // retrieve boards list
boards, err := a.app.SearchBoardsForUser(term, userID) boards, err := a.app.SearchBoardsForUserInTeam(teamID, term, userID)
if err != nil { if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return return
@ -3439,6 +3440,69 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
auditRec.Success() 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) { func (a *API) handleGetMembersForBoard(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /boards/{boardID}/members getMembersForBoard // swagger:operation GET /boards/{boardID}/members getMembersForBoard
// //

View File

@ -444,6 +444,10 @@ func (a *App) SearchBoardsForUser(term, userID string) ([]*model.Board, error) {
return a.store.SearchBoardsForUser(term, userID) 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 { func (a *App) UndeleteBoard(boardID string, modifiedBy string) error {
boards, err := a.store.GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true}) boards, err := a.store.GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true})
if err != nil { if err != nil {

View File

@ -526,7 +526,7 @@ func TestSearchBoards(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
board5 := &model.Board{ 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, Type: model.BoardTypePrivate,
TeamID: "other-team-id", 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", Name: "should return all boards where user1 is member or that are public",
Client: th.Client, Client: th.Client,
Term: "board", 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", Name: "matching a full word",
Client: th.Client, Client: th.Client,
Term: "admin", Term: "admin",
ExpectedIDs: []string{rBoard1.ID, rBoard3.ID, board5.ID}, ExpectedIDs: []string{rBoard1.ID, rBoard3.ID},
}, },
{ {
Name: "matching part of the word", Name: "matching part of the word",
Client: th.Client, Client: th.Client,
Term: "ubli", Term: "ubli",
ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, board5.ID}, ExpectedIDs: []string{rBoard1.ID, rBoard2.ID},
}, },
{ {
Name: "case insensitive", Name: "case insensitive",
Client: th.Client, Client: th.Client,
Term: "UBLI", 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", 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) { func TestGetBoard(t *testing.T) {

View File

@ -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) 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. // SearchUserChannels mocks base method.
func (m *MockStore) SearchUserChannels(arg0, arg1, arg2 string) ([]*model0.Channel, error) { func (m *MockStore) SearchUserChannels(arg0, arg1, arg2 string) ([]*model0.Channel, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()

View File

@ -684,6 +684,52 @@ func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term, userID string) ([
return s.boardsFromRows(rows) 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) { func (s *SQLStore) getBoardHistory(db sq.BaseRunner, boardID string, opts model.QueryBoardHistoryOptions) ([]*model.Board, error) {
var order string var order string
if opts.Descending { if opts.Descending {

View File

@ -388,6 +388,7 @@ func (s *SQLStore) createTempSchemaTable() error {
return nil return nil
} }
func (s *SQLStore) populateTempSchemaTable(migrations []*models.Migration, legacySchemaVersion uint32) error { func (s *SQLStore) populateTempSchemaTable(migrations []*models.Migration, legacySchemaVersion uint32) error {
query := s.getQueryBuilder(s.db). query := s.getQueryBuilder(s.db).
Insert(s.tablePrefix+tempSchemaMigrationTableName). Insert(s.tablePrefix+tempSchemaMigrationTableName).

View File

@ -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) { func (s *SQLStore) SearchUserChannels(teamID string, userID string, query string) ([]*mmModel.Channel, error) {
return s.searchUserChannels(s.db, teamID, userID, query) return s.searchUserChannels(s.db, teamID, userID, query)

View File

@ -101,6 +101,7 @@ type Store interface {
GetMembersForBoard(boardID string) ([]*model.BoardMember, error) GetMembersForBoard(boardID string) ([]*model.BoardMember, error)
GetMembersForUser(userID string) ([]*model.BoardMember, error) GetMembersForUser(userID string) ([]*model.BoardMember, error)
SearchBoardsForUser(term, userID string) ([]*model.Board, error) SearchBoardsForUser(term, userID string) ([]*model.Board, error)
SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error)
// @withTransaction // @withTransaction
CreateBoardsAndBlocksWithAdmin(bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, []*model.BoardMember, error) CreateBoardsAndBlocksWithAdmin(bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, []*model.BoardMember, error)

View File

@ -13,7 +13,6 @@ import {getMessages} from './i18n'
import {FlashMessages} from './components/flashMessages' import {FlashMessages} from './components/flashMessages'
import NewVersionBanner from './components/newVersionBanner' import NewVersionBanner from './components/newVersionBanner'
import {Utils} from './utils' import {Utils} from './utils'
import wsClient from './wsclient'
import {fetchMe, getMe} from './store/users' import {fetchMe, getMe} from './store/users'
import {getLanguage, fetchLanguage} from './store/language' import {getLanguage, fetchLanguage} from './store/language'
import {useAppSelector, useAppDispatch} from './store/hooks' import {useAppSelector, useAppDispatch} from './store/hooks'
@ -37,22 +36,6 @@ const App = (props: Props): JSX.Element => {
dispatch(fetchClientConfig()) 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(() => { useEffect(() => {
if (me) { if (me) {
TelemetryClient.setUser(me) TelemetryClient.setUser(me)

View File

@ -55,7 +55,7 @@ const BoardSwitcherDialog = (props: Props): JSX.Element => {
return [] 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'}) const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'})
return items.map((item) => { return items.map((item) => {
const resultTitle = item.title || untitledBoardTitle const resultTitle = item.title || untitledBoardTitle

View 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

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

View File

@ -10,6 +10,10 @@ import {initThemes} from './theme'
import {importNativeAppSettings} from './nativeApp' import {importNativeAppSettings} from './nativeApp'
import {UserSettings} from './userSettings' 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 '@mattermost/compass-icons/css/compass-icons.css'
import './styles/variables.scss' import './styles/variables.scss'
@ -18,15 +22,27 @@ import './styles/labels.scss'
import './styles/_markdown.scss' import './styles/_markdown.scss'
import store from './store' import store from './store'
import WithWebSockets from './components/withWebSockets'
emojiMartStore.setHandlers({getter: UserSettings.getEmojiMartSetting, setter: UserSettings.setEmojiMartSetting}) emojiMartStore.setHandlers({getter: UserSettings.getEmojiMartSetting, setter: UserSettings.setEmojiMartSetting})
importNativeAppSettings() importNativeAppSettings()
initThemes() initThemes()
const MainApp = () => {
const me = useAppSelector<IUser|null>(getMe)
return (
<WithWebSockets userId={me?.id}>
<App/>
</WithWebSockets>
)
}
ReactDOM.render( ReactDOM.render(
( (
<ReduxProvider store={store}> <ReduxProvider store={store}>
<App/> <MainApp/>
</ReduxProvider> </ReduxProvider>
), ),
document.getElementById('focalboard-app'), document.getElementById('focalboard-app'),

View File

@ -773,7 +773,21 @@ class OctoClient {
} }
async search(teamID: string, query: string): Promise<Array<Board>> { 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, { const response = await fetch(this.getBaseURL() + url, {
method: 'GET', method: 'GET',
headers: this.headers(), headers: this.headers(),

View File

@ -1,18 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useEffect, useState, useMemo, useCallback} from 'react' import React, {useEffect, useState, useMemo, useCallback} from 'react'
import {batch} from 'react-redux'
import {FormattedMessage, useIntl} from 'react-intl' import {FormattedMessage, useIntl} from 'react-intl'
import {useRouteMatch} from 'react-router-dom' import {useRouteMatch} from 'react-router-dom'
import Workspace from '../../components/workspace' import Workspace from '../../components/workspace'
import CloudMessage from '../../components/messages/cloudMessage' import CloudMessage from '../../components/messages/cloudMessage'
import octoClient from '../../octoClient' import octoClient from '../../octoClient'
import {Subscription, WSClient} from '../../wsclient'
import {Utils} from '../../utils' import {Utils} from '../../utils'
import wsClient from '../../wsclient' import {useWebsockets} from '../../hooks/websockets'
import {getCurrentBoardId, setCurrent as setCurrentBoard, fetchBoardMembers} from '../../store/boards' 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 {getCurrentViewId, setCurrent as setCurrentView} from '../../store/views'
import {initialLoad, initialReadOnlyLoad, loadBoardData} from '../../store/initialLoad' import {initialLoad, initialReadOnlyLoad, loadBoardData} from '../../store/initialLoad'
import {useAppSelector, useAppDispatch} from '../../store/hooks' 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 {setGlobalError} from '../../store/globalError'
import {UserSettings} from '../../userSettings' import {UserSettings} from '../../userSettings'
@ -20,8 +45,6 @@ import IconButton from '../../widgets/buttons/iconButton'
import CloseIcon from '../../widgets/icons/close' import CloseIcon from '../../widgets/icons/close'
import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../telemetry/telemetryClient' import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../telemetry/telemetryClient'
import {fetchUserBlockSubscriptions, getMe} from '../../store/users'
import {IUser} from '../../user'
import {Constants} from "../../constants" 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 match = useRouteMatch<{boardId: string, viewId: string, cardId?: string, teamId?: string}>()
const [mobileWarningClosed, setMobileWarningClosed] = useState(UserSettings.mobileWarningClosed) const [mobileWarningClosed, setMobileWarningClosed] = useState(UserSettings.mobileWarningClosed)
const teamId = match.params.teamId || UserSettings.lastTeamId || Constants.globalTeamId const teamId = match.params.teamId || UserSettings.lastTeamId || Constants.globalTeamId
const viewId = match.params.viewId
const me = useAppSelector<IUser|null>(getMe) const me = useAppSelector<IUser|null>(getMe)
// if we're in a legacy route and not showing a shared board, // if we're in a legacy route and not showing a shared board,
@ -68,7 +92,6 @@ const BoardPage = (props: Props): JSX.Element => {
useEffect(() => { useEffect(() => {
UserSettings.lastTeamId = teamId UserSettings.lastTeamId = teamId
octoClient.teamId = teamId octoClient.teamId = teamId
wsClient.teamId = teamId
const windowAny = (window as any) const windowAny = (window as any)
if (windowAny.setTeamInSidebar) { if (windowAny.setTeamInSidebar) {
windowAny.setTeamInSidebar(teamId) windowAny.setTeamInSidebar(teamId)
@ -82,6 +105,52 @@ const BoardPage = (props: Props): JSX.Element => {
return initialLoad return initialLoad
}, [props.readonly]) }, [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) => { const loadOrJoinBoard = useCallback(async (userId: string, boardTeamId: string, boardId: string) => {
// and fetch its data // and fetch its data
const result: any = await dispatch(loadBoardData(boardId)) 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 // and set it as most recently viewed board
UserSettings.setLastBoardID(teamId, match.params.boardId) UserSettings.setLastBoardID(teamId, match.params.boardId)
if (match.params.viewId && match.params.viewId !== Constants.globalTeamId) { if (viewId && viewId !== Constants.globalTeamId) {
dispatch(setCurrentView(match.params.viewId)) dispatch(setCurrentView(viewId))
UserSettings.setLastViewId(match.params.boardId, match.params.viewId) UserSettings.setLastViewId(match.params.boardId, viewId)
} }
if (!props.readonly && me) { if (!props.readonly && me) {
loadOrJoinBoard(me.id, teamId, match.params.boardId) 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) { if (props.readonly) {
useEffect(() => { useEffect(() => {
@ -137,12 +206,7 @@ const BoardPage = (props: Props): JSX.Element => {
<BackwardCompatibilityQueryParamsRedirect/> <BackwardCompatibilityQueryParamsRedirect/>
<SetWindowTitleAndIcon/> <SetWindowTitleAndIcon/>
<UndoRedoHotKeys/> <UndoRedoHotKeys/>
<WebsocketConnection <WebsocketConnection/>
teamId={teamId}
boardId={match.params.boardId}
readonly={props.readonly || false}
loadAction={loadAction}
/>
<CloudMessage/> <CloudMessage/>
{!mobileWarningClosed && {!mobileWarningClosed &&
@ -170,7 +234,6 @@ const BoardPage = (props: Props): JSX.Element => {
</div>} </div>}
{ {
// Don't display Templates page // Don't display Templates page
// if readonly mode and no board defined. // if readonly mode and no board defined.
(!props.readonly || activeBoardId !== undefined) && (!props.readonly || activeBoardId !== undefined) &&

View File

@ -1,90 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useEffect, useState, useMemo} from 'react' import React, {useEffect, useState} from 'react'
import {batch} from 'react-redux'
import {useLocation} from 'react-router-dom'
import {FormattedMessage} from 'react-intl' import {FormattedMessage} from 'react-intl'
import {Block} from '../../blocks/block' import wsClient, {WSClient} from '../../wsclient'
import {ContentBlock} from '../../blocks/contentBlock' import {useAppSelector} from '../../store/hooks'
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 {followBlock, getMe, unfollowBlock} from '../../store/users' import {getMe} from '../../store/users'
import {IUser} from '../../user' import {IUser} from '../../user'
import {Constants} from "../../constants"
const websocketTimeoutForBanner = 5000 const websocketTimeoutForBanner = 5000
type Props = { // WebsocketConnection component checks the websockets client for
readonly: boolean // state changes and if the connection is closed, shows a banner
teamId: string // indicating that there has been a connection error
boardId: string const WebsocketConnection = () => {
loadAction: (boardID: string) => any
}
const WebsocketConnection = (props: Props) => {
const dispatch = useAppDispatch()
const [websocketClosed, setWebsocketClosed] = useState(false) const [websocketClosed, setWebsocketClosed] = useState(false)
const queryString = new URLSearchParams(useLocation().search)
const me = useAppSelector<IUser|null>(getMe) 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(() => { 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> let timeout: ReturnType<typeof setTimeout>
const updateWebsocketState = (_: WSClient, newState: 'init'|'open'|'close'): void => { 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) { if (timeout) {
clearTimeout(timeout) clearTimeout(timeout)
} }
@ -92,42 +28,21 @@ const WebsocketConnection = (props: Props) => {
if (newState === 'close') { if (newState === 'close') {
timeout = setTimeout(() => { timeout = setTimeout(() => {
setWebsocketClosed(true) setWebsocketClosed(true)
subscribedToTeam = false
}, websocketTimeoutForBanner) }, websocketTimeoutForBanner)
} else { } else {
setWebsocketClosed(false) 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.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 () => { return () => {
if (timeout) { if (timeout) {
clearTimeout(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) wsClient.removeOnStateChange(updateWebsocketState)
} }
}, [me?.id, props.teamId, props.readonly, props.boardId, props.loadAction]) }, [me?.id])
if (websocketClosed) { if (websocketClosed) {
return ( return (

View File

@ -7,7 +7,7 @@ import {default as client} from '../octoClient'
import {Board, BoardMember} from '../blocks/board' import {Board, BoardMember} from '../blocks/board'
import {IUser} from '../user' import {IUser} from '../user'
import {initialLoad, initialReadOnlyLoad, loadBoardData} from './initialLoad' import {initialLoad, initialReadOnlyLoad, loadBoardData, loadBoards} from './initialLoad'
import {addBoardUsers, removeBoardUsersById, setBoardUsers} from './users' 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) { if (action.payload.length === 0) {
return return
} }
@ -140,7 +140,7 @@ const boardsSlice = createSlice({
} }
} }
}, },
updateMembers, updateMembers: updateMembersHandler,
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
@ -178,6 +178,12 @@ const boardsSlice = createSlice({
state.myBoardMemberships[boardMember.boardId] = boardMember 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) => { builder.addCase(fetchBoardMembers.fulfilled, (state, action) => {
if (action.payload.length === 0) { if (action.payload.length === 0) {
return return
@ -192,11 +198,11 @@ const boardsSlice = createSlice({
}, {}) }, {})
state.membersInBoards[boardId] = boardMembersMap 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 {reducer} = boardsSlice
export const getBoards = (state: RootState): {[key: string]: Board} => state.boards?.boards || {} export const getBoards = (state: RootState): {[key: string]: Board} => state.boards?.boards || {}

View File

@ -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 getUserBlockSubscriptions = (state: RootState): Array<Subscription> => state.users.blockSubscriptions
export const getUserBlockSubscriptionList = createSelector( export const getUserBlockSubscriptionList = createSelector(

View File

@ -35,6 +35,7 @@ export const refreshCurrentTeam = createAsyncThunk(
) )
type TeamState = { type TeamState = {
currentId: string
current: Team | null current: Team | null
allTeams: Array<Team> allTeams: Array<Team>
} }
@ -43,11 +44,13 @@ const teamSlice = createSlice({
name: 'teams', name: 'teams',
initialState: { initialState: {
current: null, current: null,
currentId: '',
allTeams: [], allTeams: [],
} as TeamState, } as TeamState,
reducers: { reducers: {
setTeam: (state, action: PayloadAction<string>) => { setTeam: (state, action: PayloadAction<string>) => {
const teamID = action.payload const teamID = action.payload
state.currentId = teamID
const team = state.allTeams.find((t) => t.id === teamID) const team = state.allTeams.find((t) => t.id === teamID)
if (!team) { if (!team) {
Utils.log(`Unable to find team in store. TeamID: ${teamID}`) Utils.log(`Unable to find team in store. TeamID: ${teamID}`)
@ -80,6 +83,7 @@ const teamSlice = createSlice({
export const {setTeam} = teamSlice.actions export const {setTeam} = teamSlice.actions
export const {reducer} = teamSlice 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 getCurrentTeam = (state: RootState): Team|null => state.teams.current
export const getFirstTeam = (state: RootState): Team|null => state.teams.allTeams[0] export const getFirstTeam = (state: RootState): Team|null => state.teams.allTeams[0]
export const getAllTeams = (state: RootState): Array<Team> => state.teams.allTeams export const getAllTeams = (state: RootState): Array<Team> => state.teams.allTeams

View File

@ -97,10 +97,15 @@ type ChangeHandlers = {
BoardMember: OnChangeHandler[] BoardMember: OnChangeHandler[]
} }
type Subscriptions = {
Teams: Record<string, number>
}
class WSClient { class WSClient {
ws: WebSocket|null = null ws: WebSocket|null = null
client: MMWebSocketClient|null = null client: MMWebSocketClient|null = null
onPluginReconnect: null|(() => void) = null onPluginReconnect: null|(() => void) = null
token = ''
pluginId = '' pluginId = ''
pluginVersion = '' pluginVersion = ''
teamId = '' teamId = ''
@ -121,6 +126,7 @@ class WSClient {
private updatedData: UpdatedData = {Blocks: [], Categories: [], BoardCategories: [], Boards: [], BoardMembers: []} private updatedData: UpdatedData = {Blocks: [], Categories: [], BoardCategories: [], Boards: [], BoardMembers: []}
private updateTimeout?: NodeJS.Timeout private updateTimeout?: NodeJS.Timeout
private errorPollId?: NodeJS.Timeout private errorPollId?: NodeJS.Timeout
private subscriptions: Subscriptions = {Teams: {}}
private logged = false private logged = false
@ -154,6 +160,17 @@ class WSClient {
Utils.log(`WSClient initialised for plugin id "${pluginId}"`) 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 { sendCommand(command: WSCommand): void {
if (this.client !== null) { if (this.client !== null) {
const {action, ...data} = command const {action, ...data} = command
@ -164,6 +181,30 @@ class WSClient {
this.ws?.send(JSON.stringify(command)) 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 { addOnChange(handler: OnChangeHandler, type: ChangeHandlerType): void {
switch (type) { switch (type) {
case 'block': case 'block':
@ -275,6 +316,10 @@ class WSClient {
const onConnect = () => { const onConnect = () => {
Utils.log('WSClient in plugin mode, reusing Mattermost WS connection') 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) { for (const handler of this.onStateChange) {
handler(this, 'open') handler(this, 'open')
} }
@ -341,6 +386,16 @@ class WSClient {
ws.onopen = () => { ws.onopen = () => {
Utils.log('WSClient webSocket opened.') Utils.log('WSClient webSocket opened.')
this.state = 'open' 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) { for (const handler of this.onStateChange) {
handler(this, 'open') handler(this, 'open')
} }
@ -363,6 +418,8 @@ class WSClient {
} }
this.state = 'close' this.state = 'close'
setTimeout(() => { setTimeout(() => {
// ToDo: assert that this actually runs the onopen
// contents (auth + this.subscribe())
this.open() this.open()
for (const handler of this.onReconnect) { for (const handler of this.onReconnect) {
handler(this) handler(this)
@ -498,22 +555,55 @@ class WSClient {
} }
} }
authenticate(teamId: string, token: string): void { authenticate(token: string): void {
if (!this.hasConn()) {
Utils.assertFailure('WSClient.addBlocks: ws is not open')
return
}
if (!token) { if (!token) {
Utils.assertFailure('WSClient trying to authenticate without a token')
return return
} }
const command = {
action: ACTION_AUTH, if (this.hasConn()) {
token, this.sendAuthenticationCommand(token)
teamId,
} }
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 { subscribeToBlocks(teamId: string, blockIds: string[], readToken = ''): void {
@ -532,34 +622,6 @@ class WSClient {
this.sendCommand(command) 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 { unsubscribeFromBlocks(teamId: string, blockIds: string[], readToken = ''): void {
if (!this.hasConn()) { if (!this.hasConn()) {
Utils.assertFailure('WSClient.removeBlocks: ws is not open') Utils.assertFailure('WSClient.removeBlocks: ws is not open')
@ -609,21 +671,6 @@ class WSClient {
}, this.notificationDelay) }, 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() { private logUpdateNotification() {
for (const block of this.updatedData.Blocks) { for (const block of this.updatedData.Blocks) {
Utils.log(`WSClient flush update block: ${block.id}`) Utils.log(`WSClient flush update block: ${block.id}`)