diff --git a/server/boards/integrationtests/boardsapp_test.go b/server/boards/integrationtests/boardsapp_test.go index 24f31e8d01..f327520a7d 100644 --- a/server/boards/integrationtests/boardsapp_test.go +++ b/server/boards/integrationtests/boardsapp_test.go @@ -77,10 +77,13 @@ func TestSetConfiguration(t *testing.T) { }) t.Run("test enable shared boards", func(t *testing.T) { + baseProductSettings := &model.ProductSettings{ + EnablePublicSharedBoards: &falseRef, + } + mmConfig := baseConfig - mmConfig.PluginSettings.Plugins = make(map[string]map[string]interface{}) - mmConfig.PluginSettings.Plugins[server.PluginName] = make(map[string]interface{}) - mmConfig.PluginSettings.Plugins[server.PluginName][server.SharedBoardsName] = true + mmConfig.ProductSettings = *baseProductSettings + mmConfig.ProductSettings.EnablePublicSharedBoards = &boolTrue config := server.CreateBoardsConfig(*mmConfig, "", "") assert.Equal(t, true, config.EnablePublicSharedBoards) }) diff --git a/server/boards/server/boards_service_util.go b/server/boards/server/boards_service_util.go index 530f0f5f32..c1de21897e 100644 --- a/server/boards/server/boards_service_util.go +++ b/server/boards/server/boards_service_util.go @@ -59,8 +59,8 @@ func CreateBoardsConfig(mmconfig mm_model.Config, baseURL string, serverID strin } enablePublicSharedBoards := false - if mmconfig.PluginSettings.Plugins[PluginName][SharedBoardsName] == true { - enablePublicSharedBoards = true + if mmconfig.ProductSettings.EnablePublicSharedBoards != nil { + enablePublicSharedBoards = *mmconfig.ProductSettings.EnablePublicSharedBoards } enableBoardsDeletion := false diff --git a/webapp/boards/src/components/__snapshots__/workspace.test.tsx.snap b/webapp/boards/src/components/__snapshots__/workspace.test.tsx.snap index f03802176e..70c70b823c 100644 --- a/webapp/boards/src/components/__snapshots__/workspace.test.tsx.snap +++ b/webapp/boards/src/components/__snapshots__/workspace.test.tsx.snap @@ -873,7 +873,7 @@ exports[`src/components/workspace return workspace readonly and showcard 1`] = ` >
{ return { ...originalModule, useRouteMatch: jest.fn(() => { - return {url: '/board/view'} + return { + params: { + teamId: 'team1', + boardId: 'boardId1', + viewId: 'viewId1', + cardId: 'cardId1', + }, + } }), } }) diff --git a/webapp/boards/src/components/kanban/__snapshots__/kanbanColumnHeader.test.tsx.snap b/webapp/boards/src/components/kanban/__snapshots__/kanbanColumnHeader.test.tsx.snap index 0dcb5a8f4f..b617f5301c 100644 --- a/webapp/boards/src/components/kanban/__snapshots__/kanbanColumnHeader.test.tsx.snap +++ b/webapp/boards/src/components/kanban/__snapshots__/kanbanColumnHeader.test.tsx.snap @@ -469,7 +469,7 @@ exports[`src/components/kanban/kanbanColumnHeader should match snapshot readonly
(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 && } @@ -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 diff --git a/webapp/boards/src/components/shareBoard/__snapshots__/shareBoard.test.tsx.snap b/webapp/boards/src/components/shareBoard/__snapshots__/shareBoard.test.tsx.snap index 634a83d9e7..60e676af98 100644 --- a/webapp/boards/src/components/shareBoard/__snapshots__/shareBoard.test.tsx.snap +++ b/webapp/boards/src/components/shareBoard/__snapshots__/shareBoard.test.tsx.snap @@ -983,11 +983,11 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Regene > - http://localhost:8065/team/team-id/shared/1/1?r=anotherToken + http://localhost:8065/boards/public/team/team-id/shared/1/1?r=anotherToken
- http://localhost:8065/team/team-id/shared/1/1?r=oneToken + http://localhost:8065/boards/public/team/team-id/shared/1/1?r=oneToken
- http://localhost:8065/team/team-id/shared/1/1?r=aToken + http://localhost:8065/boards/public/team/team-id/shared/1/1?r=aToken
{ ...originalModule, useRouteMatch: jest.fn(() => { return { - teamId: 'team1', - boardId: 'boardId1', - viewId: 'viewId1', - cardId: 'cardId1', + params: { + teamId: 'team1', + boardId: 'boardId1', + viewId: 'viewId1', + cardId: 'cardId1', + }, } }), } diff --git a/webapp/boards/src/components/shareBoard/shareBoardLoginButton.tsx b/webapp/boards/src/components/shareBoard/shareBoardLoginButton.tsx index 18b7a13e91..a0dbe94d04 100644 --- a/webapp/boards/src/components/shareBoard/shareBoardLoginButton.tsx +++ b/webapp/boards/src/components/shareBoard/shareBoardLoginButton.tsx @@ -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 ( diff --git a/webapp/boards/src/components/viewHeader/viewHeader.tsx b/webapp/boards/src/components/viewHeader/viewHeader.tsx index 93b2d529fa..54ae56974b 100644 --- a/webapp/boards/src/components/viewHeader/viewHeader.tsx +++ b/webapp/boards/src/components/viewHeader/viewHeader.tsx @@ -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} /> @@ -159,7 +162,7 @@ const ViewHeader = (props: Props) => {
- {!props.readonly && canEditBoardProperties && + {!readonly && <> {/* Card properties */} diff --git a/webapp/boards/src/components/viewTitle.tsx b/webapp/boards/src/components/viewTitle.tsx index a8b086177d..191a8b62a9 100644 --- a/webapp/boards/src/components/viewTitle.tsx +++ b/webapp/boards/src/components/viewTitle.tsx @@ -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() diff --git a/webapp/boards/src/components/workspace.test.tsx b/webapp/boards/src/components/workspace.test.tsx index d66fb09e5f..dc7c2d6d7d 100644 --- a/webapp/boards/src/components/workspace.test.tsx +++ b/webapp/boards/src/components/workspace.test.tsx @@ -114,6 +114,7 @@ jest.mock('react-router-dom', () => { useRouteMatch: jest.fn(() => { return { params: { + teamId: board.teamId, boardId: board.id, viewId: activeView.id, }, diff --git a/webapp/boards/src/hooks/permissions.tsx b/webapp/boards/src/hooks/permissions.tsx index 0c0c02b753..ce69f2c6e7 100644 --- a/webapp/boards/src/hooks/permissions.tsx +++ b/webapp/boards/src/hooks/permissions.tsx @@ -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 } diff --git a/webapp/boards/src/index.tsx b/webapp/boards/src/index.tsx index c90d4417ec..71718b2821 100644 --- a/webapp/boards/src/index.tsx +++ b/webapp/boards/src/index.tsx @@ -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 ( + + +
+ +
+
+ + + ) +} + const HeaderComponent = () => { return ( - + ) } @@ -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 = () => { diff --git a/webapp/boards/src/public/app.tsx b/webapp/boards/src/public/app.tsx new file mode 100644 index 0000000000..a45fe4d794 --- /dev/null +++ b/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 ( + + + + + + + + ) +} + +const PublicApp = (): JSX.Element => { + const language = useAppSelector(getLanguage) + + return ( + + + +
+
+ + +
+
+
+
+ ) +} + +export default React.memo(PublicApp) diff --git a/webapp/boards/src/types/mattermost-webapp/index.d.ts b/webapp/boards/src/types/mattermost-webapp/index.d.ts index d713644170..d24185587c 100644 --- a/webapp/boards/src/types/mattermost-webapp/index.d.ts +++ b/webapp/boards/src/types/mattermost-webapp/index.d.ts @@ -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 diff --git a/webapp/channels/src/components/admin_console/admin_definition.jsx b/webapp/channels/src/components/admin_console/admin_definition.jsx index 387b13825b..e528ccc4e1 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.jsx +++ b/webapp/channels/src/components/admin_console/admin_definition.jsx @@ -5746,7 +5746,6 @@ const AdminDefinition = { sectionTitle: t('admin.sidebar.products'), sectionTitleDefault: 'Products', isHidden: it.any( - it.configIsFalse('FeatureFlags', 'BoardsProduct'), it.not(it.userHasReadPermissionOnSomeResources(RESOURCE_KEYS.PRODUCTS)), ), boards: { diff --git a/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap b/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap index 381db4850c..ac3dd61521 100644 --- a/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap @@ -1072,6 +1072,37 @@ exports[`components/AdminSidebar should match snapshot 1`] = ` title="Plugin 0" /> + + } + key="products" + parentLink="/admin_console" + sectionClass="" + title={ + + } + > + + } + /> + + + } + key="products" + parentLink="/admin_console" + sectionClass="" + title={ + + } + > + + } + /> + + + } + key="products" + parentLink="/admin_console" + sectionClass="" + title={ + + } + > + + } + /> + + + } + key="products" + parentLink="/admin_console" + sectionClass="" + title={ + + } + > + + } + /> + + + } + key="products" + parentLink="/admin_console" + sectionClass="" + title={ + + } + > + + } + /> + + + } + key="products" + parentLink="/admin_console" + sectionClass="" + title={ + + } + > + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/webapp/channels/src/components/root/root.test.tsx b/webapp/channels/src/components/root/root.test.tsx index 2f0a3ccfb6..c146bae462 100644 --- a/webapp/channels/src/components/root/root.test.tsx +++ b/webapp/channels/src/components/root/root.test.tsx @@ -15,6 +15,7 @@ import Root from 'components/root/root'; import * as GlobalActions from 'actions/global_actions'; import Constants, {StoragePrefixes, WindowSizes} from 'utils/constants'; import matchMedia from 'tests/helpers/match_media.mock'; +import {ProductComponent} from 'types/store/plugins'; jest.mock('rudder-sdk-js', () => ({ identify: jest.fn(), @@ -64,7 +65,7 @@ describe('components/Root', () => { registerCustomPostRenderer: jest.fn(), initializeProducts: jest.fn(), }, - permalinkRedirectTeamName: '', + permalinkRedirectTeamName: 'myTeam', showLaunchingWorkspace: false, plugins: [], products: [], @@ -325,4 +326,33 @@ describe('components/Root', () => { wrapper.unmount(); }); }); + + describe('Routes', () => { + test('Should mount public product routes', () => { + const mainComponent = () => (

{'TestMainComponent'}

); + const publicComponent = () => (

{'TestPublicProduct'}

); + + const props = { + ...baseProps, + products: [{ + id: 'productwithpublic', + baseURL: '/productwithpublic', + mainComponent, + publicComponent, + } as unknown as ProductComponent, + { + id: 'productwithoutpublic', + baseURL: '/productwithoutpublic', + mainComponent, + publicComponent: null, + } as unknown as ProductComponent], + }; + + const wrapper = shallow(); + + (wrapper.instance() as any).setState({configLoaded: true}); + expect(wrapper).toMatchSnapshot(); + wrapper.unmount(); + }); + }); }); diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index 541dd518fc..dada1d57b1 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -650,6 +650,23 @@ export default class Root extends React.PureComponent { + {this.props.products?.filter((product) => Boolean(product.publicComponent)).map((product) => ( + { + return ( + + ); + }} + /> + ))} {this.props.products?.map((product) => ( ) => { const id = generateId(); @@ -1016,6 +1018,7 @@ export default class PluginRegistry { showTeamSidebar, showAppBar, wrapped, + publicComponent, }, }); diff --git a/webapp/channels/src/types/store/plugins.ts b/webapp/channels/src/types/store/plugins.ts index f39716c1b4..7583a4a8da 100644 --- a/webapp/channels/src/types/store/plugins.ts +++ b/webapp/channels/src/types/store/plugins.ts @@ -187,6 +187,11 @@ export type ProductComponent = { */ mainComponent: React.ComponentType; + /** + * The public component to be displayed when a public route is active. + */ + publicComponent: React.ComponentType | null; + /** * A component to fill the generic area in the center of * the global header when your route is active. diff --git a/webapp/channels/src/utils/test_helper.ts b/webapp/channels/src/utils/test_helper.ts index ed01fb0b31..fec90b0ff0 100644 --- a/webapp/channels/src/utils/test_helper.ts +++ b/webapp/channels/src/utils/test_helper.ts @@ -371,6 +371,7 @@ export class TestHelper { showTeamSidebar: false, showAppBar: false, wrapped: true, + publicComponent: null, }; } diff --git a/webapp/playbooks/src/index.tsx b/webapp/playbooks/src/index.tsx index 7c49436853..d72b244f2f 100644 --- a/webapp/playbooks/src/index.tsx +++ b/webapp/playbooks/src/index.tsx @@ -180,7 +180,8 @@ export default class Plugin { BackstageWrapped, GlobalHeaderCenter, GlobalHeaderRight, - enableTeamSidebar + enableTeamSidebar, + null ); // RHS Registration