Adds support for public routes on shared boards (#22710)

* Adds support for public routes on shared boards

* Fix linter

* Address review comments

* Update playbooks registerProduct call

* Use boards product config for setting

* update additional product locations for parameter changes

* fixes for read-only when logged in

* turn off global header and don't initialize plugin if shared board

* fix unit tests

* revert package-lock.json

* more fixes

* Remove FF check for system console setting for boards

* update tests, Product boards is displayed in system console

* only check products section of config

* update test for config change

---------

Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Miguel de la Cruz
2023-04-06 20:24:32 +02:00
коммит произвёл GitHub
родитель 71923fe311
Коммит 4cc2fed079
27 изменённых файлов: 552 добавлений и 49 удалений

Просмотреть файл

@@ -873,7 +873,7 @@ exports[`src/components/workspace return workspace readonly and showcard 1`] = `
>
<div
class="octo-board-header-cell KanbanColumnHeader"
draggable="true"
draggable="false"
style="opacity: 1;"
>
<span
@@ -901,7 +901,7 @@ exports[`src/components/workspace return workspace readonly and showcard 1`] = `
</div>
<div
class="octo-board-header-cell KanbanColumnHeader"
draggable="true"
draggable="false"
style="opacity: 1;"
>
<span
@@ -1927,7 +1927,7 @@ exports[`src/components/workspace should match snapshot with readonly 1`] = `
>
<div
class="octo-board-header-cell KanbanColumnHeader"
draggable="true"
draggable="false"
style="opacity: 1;"
>
<span
@@ -1955,7 +1955,7 @@ exports[`src/components/workspace should match snapshot with readonly 1`] = `
</div>
<div
class="octo-board-header-cell KanbanColumnHeader"
draggable="true"
draggable="false"
style="opacity: 1;"
>
<span

Просмотреть файл

@@ -29,7 +29,14 @@ jest.mock('react-router-dom', () => {
return {
...originalModule,
useRouteMatch: jest.fn(() => {
return {url: '/board/view'}
return {
params: {
teamId: 'team1',
boardId: 'boardId1',
viewId: 'viewId1',
cardId: 'cardId1',
},
}
}),
}
})

Просмотреть файл

@@ -469,7 +469,7 @@ exports[`src/components/kanban/kanbanColumnHeader should match snapshot readonly
<div>
<div
class="octo-board-header-cell KanbanColumnHeader"
draggable="true"
draggable="false"
style="opacity: 1;"
>
<span

Просмотреть файл

@@ -52,8 +52,12 @@ const defaultProperty: IPropertyTemplate = {
export default function KanbanColumnHeader(props: Props): JSX.Element {
const intl = useIntl()
const {board, activeView, group, groupByProperty} = props
let readonly = props.readonly
if(!readonly){
readonly = !useHasCurrentBoardPermissions([Permission.ManageBoardProperties])
}
const [groupTitle, setGroupTitle] = useState(group.option.value)
const canEditBoardProperties = useHasCurrentBoardPermissions([Permission.ManageBoardProperties])
const canEditOption = groupByProperty?.type !== 'person' && group.option.id
const headerRef = useRef<HTMLDivElement>(null)
@@ -79,7 +83,7 @@ export default function KanbanColumnHeader(props: Props): JSX.Element {
setGroupTitle(group.option.value)
}, [group.option.value])
if (canEditBoardProperties) {
if (!readonly) {
drop(drag(headerRef))
}
@@ -97,7 +101,7 @@ export default function KanbanColumnHeader(props: Props): JSX.Element {
ref={headerRef}
style={{opacity: isDragging ? 0.5 : 1}}
className={className}
draggable={!props.readonly && canEditBoardProperties}
draggable={!readonly}
>
{!group.option.id &&
<Label
@@ -133,7 +137,7 @@ export default function KanbanColumnHeader(props: Props): JSX.Element {
onCancel={() => {
setGroupTitle(group.option.value)
}}
readonly={props.readonly || !canEditBoardProperties}
readonly={readonly}
spellCheck={true}
/>
</Label>}
@@ -145,7 +149,7 @@ export default function KanbanColumnHeader(props: Props): JSX.Element {
onMenuClose={props.onCalculationMenuClose}
onMenuOpen={props.onCalculationMenuOpen}
cardProperties={board.cardProperties}
readonly={props.readonly || !canEditBoardProperties}
readonly={readonly}
onChange={(data: {calculation: string, propertyId: string}) => {
if (data.calculation === calculationValue && data.propertyId === calculationProperty.id) {
return

Просмотреть файл

@@ -983,11 +983,11 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Regene
>
<a
class="shareUrl"
href="http://localhost:8065/team/team-id/shared/1/1?r=anotherToken"
href="http://localhost:8065/boards/public/team/team-id/shared/1/1?r=anotherToken"
rel="noreferrer"
target="_blank"
>
http://localhost:8065/team/team-id/shared/1/1?r=anotherToken
http://localhost:8065/boards/public/team/team-id/shared/1/1?r=anotherToken
</a>
<div
class="octo-tooltip tooltip-top"
@@ -3188,11 +3188,11 @@ exports[`src/components/shareBoard/shareBoard return shareBoard, and click switc
>
<a
class="shareUrl"
href="http://localhost:8065/team/team-id/shared/1/1?r=oneToken"
href="http://localhost:8065/boards/public/team/team-id/shared/1/1?r=oneToken"
rel="noreferrer"
target="_blank"
>
http://localhost:8065/team/team-id/shared/1/1?r=oneToken
http://localhost:8065/boards/public/team/team-id/shared/1/1?r=oneToken
</a>
<div
class="octo-tooltip tooltip-top"
@@ -3461,11 +3461,11 @@ exports[`src/components/shareBoard/shareBoard return shareBoardComponent and cli
>
<a
class="shareUrl"
href="http://localhost:8065/team/team-id/shared/1/1?r=aToken"
href="http://localhost:8065/boards/public/team/team-id/shared/1/1?r=aToken"
rel="noreferrer"
target="_blank"
>
http://localhost:8065/team/team-id/shared/1/1?r=aToken
http://localhost:8065/boards/public/team/team-id/shared/1/1?r=aToken
</a>
<div
class="octo-tooltip tooltip-top"

Просмотреть файл

@@ -259,7 +259,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
viewId: match.params.viewId,
teamId: match.params.teamId,
})
shareUrl.pathname = newPath
shareUrl.pathname = `/boards/public${newPath}`
const boardPath = generatePath('/team/:teamId/:boardId/:viewId', {
boardId: match.params.boardId,

Просмотреть файл

@@ -21,10 +21,12 @@ jest.mock('react-router-dom', () => {
...originalModule,
useRouteMatch: jest.fn(() => {
return {
teamId: 'team1',
boardId: 'boardId1',
viewId: 'viewId1',
cardId: 'cardId1',
params: {
teamId: 'team1',
boardId: 'boardId1',
viewId: 'viewId1',
cardId: 'cardId1',
},
}
}),
}

Просмотреть файл

@@ -3,7 +3,7 @@
import React, {useCallback} from 'react'
import {FormattedMessage} from 'react-intl'
import {generatePath, useRouteMatch, useHistory} from 'react-router-dom'
import {generatePath, useRouteMatch} from 'react-router-dom'
import Button from 'src/widgets/buttons/button'
import TelemetryClient, {TelemetryActions, TelemetryCategory} from 'src/telemetry/telemetryClient'
@@ -12,14 +12,17 @@ import './shareBoardLoginButton.scss'
const ShareBoardLoginButton = () => {
const match = useRouteMatch<{teamId: string, boardId: string, viewId?: string, cardId?: string}>()
const history = useHistory()
const redirectQueryParam = 'r=' + encodeURIComponent(generatePath('/:boardId?/:viewId?/:cardId?', match.params))
const loginPath = '/login?' + redirectQueryParam
// Mattermost login doesn't respect the redirect query parameter
// if the user is already logged in, so we send the user to the
// board and if they are not logged in, the webapp will take care
// of the redirection
const baseURL = window.location.href.split('/boards/public')[0]
const loginPath = `${baseURL}/${generatePath('/boards/team/:teamId/:boardId?/:viewId?/:cardId?', match.params)}`
const onLoginClick = useCallback(() => {
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ShareBoardLogin)
history.push(loginPath)
location.assign(loginPath)
}, [])
return (

Просмотреть файл

@@ -59,9 +59,12 @@ const ViewHeader = (props: Props) => {
const [showFilter, setShowFilter] = useState(false)
const [lockFilterOnClose, setLockFilterOnClose] = useState(false)
const intl = useIntl()
const canEditBoardProperties = useHasCurrentBoardPermissions([Permission.ManageBoardProperties])
const {board, activeView, views, groupByProperty, cards, dateDisplayProperty} = props
let readonly = props.readonly
if(!readonly){
readonly = !useHasCurrentBoardPermissions([Permission.ManageBoardProperties])
}
const withGroupBy = activeView.fields.viewType === 'board' || activeView.fields.viewType === 'table'
const withDisplayBy = activeView.fields.viewType === 'calendar'
@@ -137,7 +140,7 @@ const ViewHeader = (props: Props) => {
}}
onChange={setViewTitle}
saveOnEsc={true}
readonly={props.readonly || !canEditBoardProperties}
readonly={readonly}
spellCheck={true}
autoExpand={false}
/>
@@ -148,7 +151,7 @@ const ViewHeader = (props: Props) => {
board={board}
activeView={activeView}
views={views}
readonly={props.readonly || !canEditBoardProperties}
readonly={readonly}
allowCreateView={allowCreateView}
/>
</MenuWrapper>
@@ -159,7 +162,7 @@ const ViewHeader = (props: Props) => {
<div className='octo-spacer'/>
{!props.readonly && canEditBoardProperties &&
{!readonly &&
<>
{/* Card properties */}

Просмотреть файл

@@ -34,9 +34,11 @@ const ViewTitle = (props: Props) => {
}, [board.id, board.icon])
const onShowDescription = useCallback(() => mutator.showBoardDescription(board.id, Boolean(board.showDescription), true), [board.id, board.showDescription])
const onHideDescription = useCallback(() => mutator.showBoardDescription(board.id, Boolean(board.showDescription), false), [board.id, board.showDescription])
const canEditBoardProperties = useHasCurrentBoardPermissions([Permission.ManageBoardProperties])
const readonly = props.readonly || !canEditBoardProperties
let readonly = props.readonly
if(!readonly){
readonly = !useHasCurrentBoardPermissions([Permission.ManageBoardProperties])
}
const intl = useIntl()

Просмотреть файл

@@ -114,6 +114,7 @@ jest.mock('react-router-dom', () => {
useRouteMatch: jest.fn(() => {
return {
params: {
teamId: board.teamId,
boardId: board.id,
viewId: activeView.id,
},

Просмотреть файл

@@ -12,13 +12,12 @@ export const useHasPermissions = (teamId: string, boardId: string, permissions:
return false
}
const member = useAppSelector(getMyBoardMembership(boardId))
const board = useAppSelector(getBoard(boardId))
if (!board) {
return false
}
const member = useAppSelector(getMyBoardMembership(boardId))
if (!member) {
return false
}

Просмотреть файл

@@ -28,6 +28,7 @@ windowAny.baseURL = '/plugins/boards'
windowAny.frontendBaseURL = '/boards'
import App from 'src/app'
import PublicApp, {publicBaseURL} from 'src/public/app'
import store from 'src/store'
import WithWebSockets from 'src/components/withWebSockets'
@@ -175,10 +176,44 @@ const MainApp = (props: Props) => {
)
}
const PublicMainApp = () => {
useEffect(() => {
document.body.classList.add('focalboard-body')
const root = document.getElementById('root')
if (root) {
while (root.firstElementChild) {
if( root.firstElementChild.id === 'focalboard-app'){
break
}
root.removeChild(root.firstElementChild)
}
root.classList.add('focalboard-plugin-root')
}
return () => {
document.body.classList.remove('focalboard-body')
if (root) {
root.classList.remove('focalboard-plugin-root')
}
}
}, [])
return (
<ErrorBoundary>
<ReduxProvider store={store}>
<div id='focalboard-app'>
<PublicApp />
</div>
<div id='focalboard-root-portal' />
</ReduxProvider>
</ErrorBoundary>
)
}
const HeaderComponent = () => {
return (
<ErrorBoundary>
<GlobalHeader history={browserHistory}/>
<GlobalHeader history={browserHistory} />
</ErrorBoundary>
)
}
@@ -243,8 +278,6 @@ export default class Plugin {
}
})
const initCurrentChannelId = mmStore.getState().entities.channels.currentChannelId
const initCurrentChannel = mmStore.getState().entities.channels.channels[initCurrentChannelId]
let lastViewedChannelId = initCurrentChannelId
@@ -274,7 +307,9 @@ export default class Plugin {
prevTeamId = currentTeamId
store.dispatch(setTeam(currentTeamId))
octoClient.teamId = currentTeamId
store.dispatch(initialLoad())
if(!window.location.pathname.includes(publicBaseURL())){
store.dispatch(initialLoad())
}
}
if (currentTeamId && currentTeamId !== prevTeamId) {
@@ -336,6 +371,9 @@ export default class Plugin {
HeaderComponent,
() => null,
true,
true,
true,
PublicMainApp,
)
const goToFocalboardTemplate = () => {

61
webapp/boards/src/public/app.tsx Обычный файл
Просмотреть файл

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {Router, Switch} from 'react-router-dom'
import {IntlProvider} from 'react-intl'
import {DndProvider} from 'react-dnd'
import {HTML5Backend} from 'react-dnd-html5-backend'
import {TouchBackend} from 'react-dnd-touch-backend'
import {createBrowserHistory} from 'history'
import BoardPage from 'src/pages/boardPage/boardPage'
import FBRoute from 'src/route'
import {getMessages} from 'src/i18n'
import FlashMessages from 'src/components/flashMessages'
import NewVersionBanner from 'src/components/newVersionBanner'
import {Utils} from 'src/utils'
import {getLanguage} from 'src/store/language'
import {useAppSelector} from 'src/store/hooks'
export const publicBaseURL = () => {
return Utils.getFrontendBaseURL() + '/public'
}
const PublicRouter = () => {
const history = createBrowserHistory({basename: publicBaseURL()})
return (
<Router history={history}>
<Switch>
<FBRoute path={['/team/:teamId/shared/:boardId?/:viewId?/:cardId?', '/shared/:boardId?/:viewId?/:cardId?']}>
<BoardPage readonly={true}/>
</FBRoute>
</Switch>
</Router>
)
}
const PublicApp = (): JSX.Element => {
const language = useAppSelector<string>(getLanguage)
return (
<IntlProvider
locale={language.split(/[_]/)[0]}
messages={getMessages(language)}
>
<DndProvider backend={Utils.isMobile() ? TouchBackend : HTML5Backend}>
<FlashMessages milliseconds={2000}/>
<div id='frame'>
<div id='main'>
<NewVersionBanner/>
<PublicRouter/>
</div>
</div>
</DndProvider>
</IntlProvider>
)
}
export default React.memo(PublicApp)

Просмотреть файл

@@ -14,7 +14,9 @@ export interface PluginRegistry {
registerCustomRoute(route: string, component: ReactResolvable): any
registerProductRoute(route: string, component: ReactResolvable): any
unregisterComponent(componentId: string): any
registerProduct(baseURL: string, switcherIcon: string, switcherText: string, switcherLinkURL: string, mainComponent: ReactResolvable, headerCentreComponent: ReactResolvable, headerRightComponent: ReactResolvable, showTeamSidebar: boolean): any
registerProduct(baseURL: string, switcherIcon: string, switcherText: string, switcherLinkURL: string,
mainComponent: ReactResolvable, headerCentreComponent: ReactResolvable, headerRightComponent: ReactResolvable,
showTeamSidebar: boolean, showAppBar: boolean, wrapped: boolean, publicComponent: ReactResolvable | null): any
registerPostWillRenderEmbedComponent(match: (embed: {type: string, data: any}) => void, component: any, toggleable: boolean): any
registerWebSocketEventHandler(event: string, handler: (e: any) => void): any
unregisterWebSocketEventHandler(event: string): any