GH-1333: Implement Screen 3 and 4 of v6.0 navigation and FTUE (#1341)

This commit is contained in:
Hossein 2021-10-11 14:16:59 -04:00 committed by GitHub
parent 319dcbfb5d
commit c9aeeb38bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 338 additions and 67 deletions

View File

@ -1,14 +1,90 @@
.EmptyCenterPanel { .EmptyCenterPanel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: auto;
padding: 80px; padding: 80px;
font-size: 15px; font-size: 15px;
color: rgba(var(--center-channel-color-rgb), 0.7); color: rgba(var(--center-channel-color-rgb));
.WorkspaceInfo { .WorkspaceInfo {
b { b {
padding-left: 5px; padding-left: 5px;
} }
} }
.content {
display: flex;
flex-direction: column;
justify-items: center;
align-items: center;
.title {
font-size: 25px;
color: var(--sys-center-channel-color);
margin-bottom: 12px;
}
.description {
text-align: center;
margin-bottom: 32px;
color: var(--sys-center-channel-color);
}
.choose-template-text {
color: var(--center-channel-color);
font-weight: 600;
}
.button-container {
display: flex;
justify-content: center;
flex-wrap: wrap;
}
.button {
display: flex;
justify-items: center;
align-items: center;
padding: 16px 32px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
box-sizing: border-box;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08);
border-radius: 8px;
margin: 20px;
max-width: 300px;
span {
margin-right: 10px;
}
.button-title {
font-weight: 600;
margin-right: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
svg {
display: flex;
}
&:hover {
cursor: pointer;
}
}
.new-template {
border: transparent;
box-shadow: none;
color: var(--button-bg);
i {
background: var(--button-bg);
color: var(--button-color);
margin: 0;
}
}
}
} }

View File

@ -1,36 +1,173 @@
// 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, {useCallback, useEffect} from 'react'
import {FormattedMessage} from 'react-intl' import {FormattedMessage, useIntl} from 'react-intl'
import {generatePath, useHistory, useRouteMatch} from 'react-router-dom'
import {getCurrentWorkspace} from '../store/workspace' import {getCurrentWorkspace} from '../store/workspace'
import {useAppSelector} from '../store/hooks' import {useAppSelector, useAppDispatch} from '../store/hooks'
import {Utils} from '../utils'
import {Board} from '../blocks/board'
import {getGlobalTemplates, fetchGlobalTemplates} from '../store/globalTemplates'
import {getSortedTemplates} from '../store/boards'
import AddIcon from '../widgets/icons/add'
import BoardIcon from '../widgets/icons/board'
import octoClient from '../octoClient'
import {addBoardTemplateClicked, addBoardClicked} from './sidebar/sidebarAddBoardMenu'
import {addBoardFromTemplate, BoardTemplateButtonMenu} from './sidebar/boardTemplateMenuItem'
import './emptyCenterPanel.scss' import './emptyCenterPanel.scss'
const EmptyCenterPanel = React.memo(() => { type ButtonProps = {
const workspace = useAppSelector(getCurrentWorkspace) buttonIcon: string | React.ReactNode,
title: string,
readonly: boolean,
onClick: () => void,
showBoard?: (boardId: string) => void
boardTemplate?: Board
classNames?: string
}
const PanelButton = React.memo((props: ButtonProps) => {
const {onClick, buttonIcon, title, readonly, showBoard, boardTemplate, classNames} = props
return ( return (
<div className='EmptyCenterPanel'> <div
{workspace && workspace.id !== '0' && onClick={onClick}
<div className='WorkspaceInfo'> className={`button ${classNames || ''}`}
<FormattedMessage >
id='EmptyCenterPanel.workspace' <span>{buttonIcon}</span>
defaultMessage='This is the workspace for:' <span className='button-title'>{title}</span>
{!readonly && showBoard && boardTemplate &&
<BoardTemplateButtonMenu
showBoard={showBoard}
boardTemplate={boardTemplate}
/> />
<b>
{workspace.title}
</b>
</div>
} }
<div className='Hint'>
<FormattedMessage
id='EmptyCenterPanel.no-content'
defaultMessage='Add or select a board from the sidebar to get started.'
/>
</div>
</div> </div>
) )
}) })
const EmptyCenterPanel = React.memo(() => {
const workspace = useAppSelector(getCurrentWorkspace)
const templates = useAppSelector(getSortedTemplates)
const globalTemplates = useAppSelector<Board[]>(getGlobalTemplates)
const history = useHistory()
const dispatch = useAppDispatch()
const intl = useIntl()
const match = useRouteMatch<{boardId: string, viewId?: string}>()
useEffect(() => {
if (octoClient.workspaceId !== '0' && globalTemplates.length === 0) {
dispatch(fetchGlobalTemplates())
}
}, [octoClient.workspaceId])
const showBoard = useCallback((boardId) => {
const params = {...match.params, boardId: boardId || ''}
delete params.viewId
const newPath = generatePath(match.path, params)
history.push(newPath)
}, [match, history])
const newTemplateClicked = () => addBoardTemplateClicked(showBoard, intl)
const emptyBoardClicked = () => addBoardClicked(showBoard, intl)
if (!Utils.isFocalboardPlugin()) {
return (
<div className='EmptyCenterPanel'>
<div className='Hint'>
<FormattedMessage
id='EmptyCenterPanel.no-content'
defaultMessage='Add or select a board from the sidebar to get started.'
/>
</div>
</div>
)
}
return (
<div className='EmptyCenterPanel'>
<div className='content'>
<span className='title'>
<FormattedMessage
id='EmptyCenterPanel.plugin.no-content-title'
defaultMessage='Create a Board in {workspaceName}'
values={{workspaceName: workspace?.title}}
/>
</span>
<span className='description'>
<FormattedMessage
id='EmptyCenterPanel.plugin.no-content-description'
defaultMessage='Add a board to the sidebar using any of the templates defined below or start from scratch.{lineBreak} Members of "{workspaceName}" will have access to boards created here.'
values={{
workspaceName: <b>{workspace?.title}</b>,
lineBreak: <br/>,
}}
/>
</span>
<span className='choose-template-text'>
<FormattedMessage
id='EmptyCenterPanel.plugin.choose-a-template'
defaultMessage='Choose a template'
/>
</span>
<div className='button-container'>
{templates.map((template) =>
(
<PanelButton
key={template.id}
title={template.title}
buttonIcon={template.fields.icon}
readonly={false}
onClick={() => addBoardFromTemplate(intl, showBoard, template.id)}
showBoard={showBoard}
boardTemplate={template}
/>
),
)}
{globalTemplates.map((template) =>
(
<PanelButton
key={template.id}
title={template.title}
buttonIcon={template.fields.icon}
readonly={true}
onClick={() => addBoardFromTemplate(intl, showBoard, template.id, undefined, true)}
/>
),
)}
<PanelButton
key={'new-template'}
title={intl.formatMessage({id: 'EmptyCenterPanel.plugin.new-template', defaultMessage: 'New template'})}
buttonIcon={<AddIcon/>}
readonly={true}
onClick={newTemplateClicked}
classNames='new-template'
/>
</div>
<span className='choose-template-text'>
<FormattedMessage
id='EmptyCenterPanel.plugin.no-content-or'
defaultMessage='or'
/>
</span>
<PanelButton
key={'start-with-an-empty-board'}
title={intl.formatMessage({id: 'EmptyCenterPanel.plugin.empty-board', defaultMessage: 'Start with an Empty Board'})}
buttonIcon={<BoardIcon/>}
readonly={true}
onClick={emptyBoardClicked}
/>
<FormattedMessage
id='EmptyCenterPanel.plugin.end-message'
defaultMessage='You can change the channel using the switcher in the sidebar.'
/>
</div>
</div>
)
})
export default EmptyCenterPanel export default EmptyCenterPanel

View File

@ -13,14 +13,7 @@ import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper' import MenuWrapper from '../../widgets/menuWrapper'
import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../telemetry/telemetryClient' import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../telemetry/telemetryClient'
type Props = { export const addBoardFromTemplate = async (intl: IntlShape, showBoard: (id: string) => void, boardTemplateId: string, activeBoardId?: string, global = false) => {
boardTemplate: Board
isGlobal: boolean
showBoard: (id: string) => void
activeBoardId?: string
}
const addBoardFromTemplate = async (intl: IntlShape, showBoard: (id: string) => void, boardTemplateId: string, activeBoardId?: string, global = false) => {
const oldBoardId = activeBoardId const oldBoardId = activeBoardId
const afterRedo = async (newBoardId: string) => { const afterRedo = async (newBoardId: string) => {
showBoard(newBoardId) showBoard(newBoardId)
@ -41,8 +34,49 @@ const addBoardFromTemplate = async (intl: IntlShape, showBoard: (id: string) =>
} }
} }
type ButtonProps = {
showBoard: (id: string) => void
boardTemplate: Board
}
export const BoardTemplateButtonMenu = React.memo((props: ButtonProps) => {
const intl = useIntl()
const {showBoard, boardTemplate} = props
return (
<MenuWrapper stopPropagationOnToggle={true}>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='right'>
<Menu.Text
icon={<EditIcon/>}
id='edit'
name={intl.formatMessage({id: 'Sidebar.edit-template', defaultMessage: 'Edit'})}
onClick={() => {
showBoard(boardTemplate.id || '')
}}
/>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'Sidebar.delete-template', defaultMessage: 'Delete'})}
onClick={async () => {
await mutator.deleteBlock(boardTemplate, 'delete board template')
}}
/>
</Menu>
</MenuWrapper>
)
})
type Props = {
boardTemplate: Board
isGlobal: boolean
showBoard: (id: string) => void
activeBoardId?: string
}
const BoardTemplateMenuItem = React.memo((props: Props) => { const BoardTemplateMenuItem = React.memo((props: Props) => {
const {boardTemplate, isGlobal, activeBoardId} = props const {boardTemplate, isGlobal, activeBoardId, showBoard} = props
const intl = useIntl() const intl = useIntl()
const displayName = boardTemplate.title || intl.formatMessage({id: 'Sidebar.untitled', defaultMessage: 'Untitled'}) const displayName = boardTemplate.title || intl.formatMessage({id: 'Sidebar.untitled', defaultMessage: 'Untitled'})
@ -54,30 +88,13 @@ const BoardTemplateMenuItem = React.memo((props: Props) => {
name={displayName} name={displayName}
icon={<div className='Icon'>{boardTemplate.fields.icon}</div>} icon={<div className='Icon'>{boardTemplate.fields.icon}</div>}
onClick={() => { onClick={() => {
addBoardFromTemplate(intl, props.showBoard, boardTemplate.id || '', activeBoardId, isGlobal) addBoardFromTemplate(intl, showBoard, boardTemplate.id || '', activeBoardId, isGlobal)
}} }}
rightIcon={!isGlobal && rightIcon={!isGlobal &&
<MenuWrapper stopPropagationOnToggle={true}> <BoardTemplateButtonMenu
<IconButton icon={<OptionsIcon/>}/> boardTemplate={boardTemplate}
<Menu position='right'> showBoard={showBoard}
<Menu.Text />
icon={<EditIcon/>}
id='edit'
name={intl.formatMessage({id: 'Sidebar.edit-template', defaultMessage: 'Edit'})}
onClick={() => {
props.showBoard(boardTemplate.id || '')
}}
/>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'Sidebar.delete-template', defaultMessage: 'Delete'})}
onClick={async () => {
await mutator.deleteBlock(boardTemplate, 'delete board template')
}}
/>
</Menu>
</MenuWrapper>
} }
/> />
) )

View File

@ -145,7 +145,7 @@ const Sidebar = React.memo((props: Props) => {
<div className='octo-spacer'/> <div className='octo-spacer'/>
{ {
!props.isDashboard && (!props.isDashboard && !Utils.isFocalboardPlugin()) &&
<SidebarAddBoardMenu <SidebarAddBoardMenu
activeBoardId={props.activeBoardId} activeBoardId={props.activeBoardId}
/> />

View File

@ -24,7 +24,7 @@ type Props = {
activeBoardId?: string activeBoardId?: string
} }
const addBoardClicked = async (showBoard: (id: string) => void, intl: IntlShape, activeBoardId?: string) => { export const addBoardClicked = async (showBoard: (id: string) => void, intl: IntlShape, activeBoardId?: string) => {
const oldBoardId = activeBoardId const oldBoardId = activeBoardId
const board = createBoard() const board = createBoard()
@ -50,10 +50,11 @@ const addBoardClicked = async (showBoard: (id: string) => void, intl: IntlShape,
) )
} }
const addBoardTemplateClicked = async (showBoard: (id: string) => void, intl: IntlShape, activeBoardId?: string) => { export const addBoardTemplateClicked = async (showBoard: (id: string) => void, intl: IntlShape, activeBoardId?: string) => {
const boardTemplate = createBoard() const boardTemplate = createBoard()
boardTemplate.rootId = boardTemplate.id boardTemplate.rootId = boardTemplate.id
boardTemplate.fields.isTemplate = true boardTemplate.fields.isTemplate = true
boardTemplate.title = intl.formatMessage({id: 'View.NewTemplateTitle', defaultMessage: 'Untitled Template'})
const view = createBoardView() const view = createBoardView()
view.fields.viewType = 'board' view.fields.viewType = 'board'

View File

@ -100,7 +100,9 @@ const Workspace = React.memo((props: Props) => {
defaultMessage="You're editing a board template." defaultMessage="You're editing a board template."
/> />
</div>} </div>}
<CenterContent readonly={props.readonly}/> <CenterContent
readonly={props.readonly}
/>
</div> </div>
</div> </div>
) )

View File

@ -3,8 +3,8 @@
.WorkspaceSwitcherWrapper { .WorkspaceSwitcherWrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 4px; gap: 10px;
width: 100%; width: 100%;
position: relative; position: relative;
padding: 0 16px; padding: 0 16px;
@ -21,8 +21,22 @@
border-radius: 4px; border-radius: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
} }
.add-workspace-icon {
display: flex;
i {
color: rgb(var(--sidebar-text-rgb));
align-self: center;
}
&:hover {
cursor: pointer;
}
}
} }
.WorkspaceSwitcher { .WorkspaceSwitcher {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -31,6 +45,8 @@
padding: 10px 16px; padding: 10px 16px;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
flex: 1;
max-width: 175px;
span { span {
max-width: 90%; max-width: 90%;

View File

@ -7,6 +7,10 @@ import {useHistory} from 'react-router-dom'
import {IWorkspace} from '../../blocks/workspace' import {IWorkspace} from '../../blocks/workspace'
import ChevronDown from '../../widgets/icons/chevronDown' import ChevronDown from '../../widgets/icons/chevronDown'
import AddIcon from '../../widgets/icons/add'
import {setCurrent as setCurrentBoard} from '../../store/boards'
import {setCurrent as setCurrentView} from '../../store/views'
import {useAppDispatch} from '../../store/hooks'
import {UserSettings} from '../../userSettings' import {UserSettings} from '../../userSettings'
@ -18,9 +22,18 @@ type Props = {
const WorkspaceSwitcher = (props: Props): JSX.Element => { const WorkspaceSwitcher = (props: Props): JSX.Element => {
const history = useHistory() const history = useHistory()
const {activeWorkspace} = props
const dispatch = useAppDispatch()
const [showMenu, setShowMenu] = useState<boolean>(false) const [showMenu, setShowMenu] = useState<boolean>(false)
const goToEmptyCenterPanel = () => {
UserSettings.lastBoardId = null
UserSettings.lastViewId = null
dispatch(setCurrentBoard(''))
dispatch(setCurrentView(''))
history.replace(`/workspace/${activeWorkspace?.id}`)
}
return ( return (
<div className={'WorkspaceSwitcherWrapper'}> <div className={'WorkspaceSwitcherWrapper'}>
<div <div
@ -31,13 +44,13 @@ const WorkspaceSwitcher = (props: Props): JSX.Element => {
} }
}} }}
> >
<span>{props.activeWorkspace?.title || DashboardOption.label}</span> <span>{activeWorkspace?.title || DashboardOption.label}</span>
<ChevronDown/> <ChevronDown/>
</div> </div>
{ {
showMenu && showMenu &&
<WorkspaceOptions <WorkspaceOptions
activeWorkspaceId={props.activeWorkspace?.id || DashboardOption.value} activeWorkspaceId={activeWorkspace?.id || DashboardOption.value}
onBlur={() => { onBlur={() => {
setShowMenu(false) setShowMenu(false)
}} }}
@ -56,6 +69,14 @@ const WorkspaceSwitcher = (props: Props): JSX.Element => {
}} }}
/> />
} }
{activeWorkspace &&
<span
className='add-workspace-icon'
onClick={goToEmptyCenterPanel}
>
<AddIcon/>
</span>
}
</div> </div>
) )
} }

View File

@ -3,7 +3,7 @@
import React, {useEffect, useState} from 'react' import React, {useEffect, useState} from 'react'
import {batch} from 'react-redux' import {batch} from 'react-redux'
import {FormattedMessage, useIntl} from 'react-intl' import {FormattedMessage, useIntl} from 'react-intl'
import {generatePath, Redirect, useHistory, useRouteMatch} from 'react-router-dom' import {generatePath, Redirect, useHistory, useRouteMatch, useLocation} from 'react-router-dom'
import {useHotkeys} from 'react-hotkeys-hook' import {useHotkeys} from 'react-hotkeys-hook'
import {Block} from '../blocks/block' import {Block} from '../blocks/block'
@ -47,6 +47,7 @@ const BoardPage = (props: Props): JSX.Element => {
const history = useHistory() const history = useHistory()
const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string, workspaceId?: string}>() const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string, workspaceId?: string}>()
const [websocketClosed, setWebsocketClosed] = useState(false) const [websocketClosed, setWebsocketClosed] = useState(false)
const queryString = new URLSearchParams(useLocation().search)
const [mobileWarningClosed, setMobileWarningClosed] = useState(UserSettings.mobileWarningClosed) const [mobileWarningClosed, setMobileWarningClosed] = useState(UserSettings.mobileWarningClosed)
let workspaceId = match.params.workspaceId || UserSettings.lastWorkspaceId || '0' let workspaceId = match.params.workspaceId || UserSettings.lastWorkspaceId || '0'
@ -66,7 +67,6 @@ const BoardPage = (props: Props): JSX.Element => {
useEffect(() => { useEffect(() => {
// Backward compatibility: This can be removed in the future, this is for // Backward compatibility: This can be removed in the future, this is for
// transform the old query params into routes // transform the old query params into routes
const queryString = new URLSearchParams(history.location.search)
const queryBoardId = queryString.get('id') const queryBoardId = queryString.get('id')
const params = {...match.params} const params = {...match.params}
let needsRedirect = false let needsRedirect = false
@ -150,7 +150,6 @@ const BoardPage = (props: Props): JSX.Element => {
let token = localStorage.getItem('focalboardSessionId') || '' let token = localStorage.getItem('focalboardSessionId') || ''
if (props.readonly) { if (props.readonly) {
loadAction = initialReadOnlyLoad loadAction = initialReadOnlyLoad
const queryString = new URLSearchParams(history.location.search)
token = token || queryString.get('r') || '' token = token || queryString.get('r') || ''
} }
dispatch(loadAction(match.params.boardId)) dispatch(loadAction(match.params.boardId))
@ -286,7 +285,9 @@ const BoardPage = (props: Props): JSX.Element => {
<div className='error'> <div className='error'>
{intl.formatMessage({id: 'BoardPage.syncFailed', defaultMessage: 'Board may be deleted or access revoked.'})} {intl.formatMessage({id: 'BoardPage.syncFailed', defaultMessage: 'Board may be deleted or access revoked.'})}
</div>} </div>}
<Workspace readonly={props.readonly || false}/> <Workspace
readonly={props.readonly || false}
/>
</div> </div>
) )
} }