1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-12-24 13:43:12 +02:00

Merge Onboarding feature branch into main (#2406)

* Persistent user config (#2133)

* Added user config API

* Add unit tests

* lint fix

* Fixed webapp tests

* Fixed webapp tests

* Updated props in store after updating

* Minor fixes

* Removed redundent data from audit logs

* Onboarding Tour (#2287)

* Created private board

* Roughly displayed tour

* Synced with Dhama's changes

* WIP

* Trying to add GIF

* Added 3 tour steps

* WIP

* WIP

* WIP

* checked in missed file

* Synced with feature branch

* WIp

* Adde skip tour option

* Fixed image loading for on-prem

* Made tour work on presonal server:

* Adde missed file

* Adding telemetry

* Adding telemetry

* Added tour tip telemetry

* Fixed pulsating dot styling for personal server

* reverted personal config

* Added reset tour button

* Displayed share tour tip of feature is enabled

* Lint fixes

* Fixed webapp tests

* Fixed webapp tests

* Completed webapp tests

* Completed webapp tests

* Webapp lint fixes

* Added server tests

* Testing cypress skip tour fix

* Fixed Cypress tests

* Added share board tour step

* Added share board tour step

* webapp lint fixes

* Updated logic to pick welcome board

* Updated tests:

* lint fixes

* Updating UI changes

* Fixed a bug causing card tour to re-appear

* FIxed minor issue

* FIxed bug where card tour didn't start in clickingh on card

* Fixed tests

* Make update user props use string instead of interface

* Fixed a value type

* Updating gif size

* Updating resolution breakpoint

* Updating tutorial tip

* Updating view selector

* Refactored tour components

* Misc fixes

* minor refactoring

* GH-2258: allow date range to overflow (#2268)

* allow date range to overflow

* Fixed issue with date overflowing into neighbouring column

Co-authored-by: Harshil Sharma <harshilsharma63@gmail.com>
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>

* Update readme with accurate Linux standalone app build instructions (#2351)

* Bump follow-redirects from 1.14.7 to 1.14.8 in /experiments/webext (#2339)

Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.7 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.7...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Switch component style fixed: selector specificity increased by adding additional class. (#2179)

* Adding sever side undelete endpoint (#2222)

* Adding sever side undelete endpoint

* Removing long lines golangci-lint errors

* Fixing linter errors

* Fixing a test problem

* Fixing tests

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>

* Removing transactions from sqlite backend (#2361)

* Removing transactions from sqlite backend

* Skipping tests in sqlite because the lack of transactions

* Generating the mocks

* Fixing golangci-lint

* Fixing problem opening the tour tooltip on card open

* Fixing texts missmatch

* Adding the Product Tour entry in the user settings menu

* Fixing some tests

* Fixing tests

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Co-authored-by: Doug Lauder <wiggin77@warpmail.net>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: kamre <eremchenko@gmail.com>
Co-authored-by: Jesús Espino <jespinog@gmail.com>

* Restored package json

* Restored package json

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Co-authored-by: Doug Lauder <wiggin77@warpmail.net>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: kamre <eremchenko@gmail.com>
Co-authored-by: Jesús Espino <jespinog@gmail.com>
This commit is contained in:
Harshil Sharma 2022-02-28 16:58:16 +05:30 committed by GitHub
parent cd6be86a36
commit ab3bf6312c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
136 changed files with 9286 additions and 3948 deletions

View File

@ -105,7 +105,7 @@ func (p *Plugin) OnActivate() error {
return fmt.Errorf("error initializing the DB: %w", err)
}
if cfg.AuthMode == server.MattermostAuthMod {
layeredStore, err2 := mattermostauthlayer.New(cfg.DBType, sqlDB, db, logger)
layeredStore, err2 := mattermostauthlayer.New(cfg.DBType, sqlDB, db, logger, p.API)
if err2 != nil {
return fmt.Errorf("error initializing the DB: %w", err2)
}
@ -329,10 +329,10 @@ func defaultLoggingConfig() string {
"Filename": "focalboard_errors.log",
"MaxAgeDays": 0,
"MaxBackups": 5,
"MaxSizeMB": 10
"MaxSizeMB": 10
},
"MaxQueueSize": 1000
}
}
}`
}

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@
import React, {useEffect} from 'react'
import {Store, Action} from 'redux'
import {Provider as ReduxProvider} from 'react-redux'
import {createBrowserHistory, History} from 'history'
import {rudderAnalytics, RudderTelemetryHandler} from 'mattermost-redux/client/rudder'
@ -13,10 +14,9 @@ windowAny.baseURL = '/plugins/focalboard'
windowAny.frontendBaseURL = '/boards'
windowAny.isFocalboardPlugin = true
import {ClientConfig} from 'mattermost-redux/types/config'
import App from '../../../webapp/src/app'
import store from '../../../webapp/src/store'
import {Utils} from '../../../webapp/src/utils'
import GlobalHeader from '../../../webapp/src/components/globalHeader/globalHeader'
import FocalboardIcon from '../../../webapp/src/widgets/icons/logo'
import {setMattermostTheme} from '../../../webapp/src/theme'
@ -67,6 +67,46 @@ type Props = {
webSocketClient: MMWebSocketClient
}
function customHistory() {
const history = createBrowserHistory({basename: Utils.getFrontendBaseURL()})
if (Utils.isDesktop()) {
window.addEventListener('message', (event: MessageEvent) => {
if (event.origin !== windowAny.location.origin) {
return
}
const pathName = event.data.message?.pathName
if (!pathName || !pathName.startsWith(windowAny.frontendBaseURL)) {
return
}
Utils.log(`Navigating Boards to ${pathName}`)
history.replace(pathName.replace(windowAny.frontendBaseURL, ''))
})
}
return {
...history,
push: (path: string, state?: unknown) => {
if (Utils.isDesktop()) {
windowAny.postMessage(
{
type: 'browser-history-push',
message: {
path: `${windowAny.frontendBaseURL}${path}`,
},
},
windowAny.location.origin,
)
} else {
history.push(path, state as Record<string, never>)
}
},
}
}
let browserHistory: History<unknown>
const MainApp = (props: Props) => {
wsClient.initPlugin(manifest.id, manifest.version, props.webSocketClient)
@ -91,7 +131,7 @@ const MainApp = (props: Props) => {
<ErrorBoundary>
<ReduxProvider store={store}>
<div id='focalboard-app'>
<App/>
<App history={browserHistory}/>
</div>
<div id='focalboard-root-portal'/>
</ReduxProvider>
@ -102,7 +142,7 @@ const MainApp = (props: Props) => {
const HeaderComponent = () => {
return (
<ErrorBoundary>
<GlobalHeader/>
<GlobalHeader history={browserHistory}/>
</ErrorBoundary>
)
}
@ -117,6 +157,7 @@ export default class Plugin {
const subpath = siteURL ? getSubpath(siteURL) : ''
windowAny.frontendBaseURL = subpath + windowAny.frontendBaseURL
windowAny.baseURL = subpath + windowAny.baseURL
browserHistory = customHistory()
this.registry = registry

View File

@ -111,7 +111,7 @@ module.exports = {
exclude: [/node_modules/],
},
{
test: /\.(png|eot|tiff|svg|woff2|woff|ttf|jpg)$/,
test: /\.(png|eot|tiff|svg|woff2|woff|ttf|jpg|gif)$/,
use: [
{
loader: 'file-loader',

View File

@ -87,6 +87,7 @@ func (a *API) RegisterRoutes(r *mux.Router) {
apiv1.HandleFunc("/users/me", a.sessionRequired(a.handleGetMe)).Methods("GET")
apiv1.HandleFunc("/users/{userID}", a.sessionRequired(a.handleGetUser)).Methods("GET")
apiv1.HandleFunc("/users/{userID}/changepassword", a.sessionRequired(a.handleChangePassword)).Methods("POST")
apiv1.HandleFunc("/users/{userID}/config", a.sessionRequired(a.handleUpdateUserConfig)).Methods(http.MethodPut)
apiv1.HandleFunc("/login", a.handleLogin).Methods("POST")
apiv1.HandleFunc("/logout", a.sessionRequired(a.handleLogout)).Methods("POST")
@ -107,6 +108,9 @@ func (a *API) RegisterRoutes(r *mux.Router) {
apiv1.HandleFunc("/workspaces/{workspaceID}/subscriptions/{blockID}/{subscriberID}", a.sessionRequired(a.handleDeleteSubscription)).Methods("DELETE")
apiv1.HandleFunc("/workspaces/{workspaceID}/subscriptions/{subscriberID}", a.sessionRequired(a.handleGetSubscriptions)).Methods("GET")
// onboarding tour endpoints
apiv1.HandleFunc("/onboard", a.sessionRequired(a.handleOnboard)).Methods(http.MethodPost)
// archives
apiv1.HandleFunc("/workspaces/{workspaceID}/archive/export", a.sessionRequired(a.handleArchiveExport)).Methods("GET")
apiv1.HandleFunc("/workspaces/{workspaceID}/archive/import", a.sessionRequired(a.handleArchiveImport)).Methods("POST")
@ -333,25 +337,6 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
auditRec.Success()
}
func stampModificationMetadata(r *http.Request, blocks []model.Block, auditRec *audit.Record) {
ctx := r.Context()
session := ctx.Value(sessionContextKey).(*model.Session)
userID := session.UserID
if userID == model.SingleUser {
userID = ""
}
now := utils.GetMillis()
for i := range blocks {
blocks[i].ModifiedBy = userID
blocks[i].UpdateAt = now
if auditRec != nil {
auditRec.AddMeta("block_"+strconv.FormatInt(int64(i), 10), blocks[i])
}
}
}
func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
// swagger:operation POST /api/v1/workspaces/{workspaceID}/blocks updateBlocks
//
@ -436,10 +421,11 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
auditRec := a.makeAuditRecord(r, "postBlocks", audit.Fail)
defer a.audit.LogRecord(audit.LevelModify, auditRec)
stampModificationMetadata(r, blocks, auditRec)
ctx := r.Context()
session := ctx.Value(sessionContextKey).(*model.Session)
userID := session.UserID
model.StampModificationMetadata(userID, blocks, auditRec)
// this query param exists when creating template from board, or board from template
sourceBoardID := r.URL.Query().Get("sourceBoardID")
@ -470,6 +456,80 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
auditRec.Success()
}
func (a *API) handleUpdateUserConfig(w http.ResponseWriter, r *http.Request) {
// swagger:operation PATCH /api/v1/users/{userID}/config updateUserConfig
//
// Updates user config
//
// ---
// produces:
// - application/json
// parameters:
// - name: userID
// in: path
// description: User ID
// required: true
// type: string
// - name: Body
// in: body
// description: User config patch to apply
// required: true
// schema:
// "$ref": "#/definitions/UserPropPatch"
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse
requestBody, err := ioutil.ReadAll(r.Body)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
var patch *model.UserPropPatch
err = json.Unmarshal(requestBody, &patch)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
vars := mux.Vars(r)
userID := vars["userID"]
ctx := r.Context()
session := ctx.Value(sessionContextKey).(*model.Session)
auditRec := a.makeAuditRecord(r, "updateUserConfig", audit.Fail)
defer a.audit.LogRecord(audit.LevelModify, auditRec)
// a user can update only own config
if userID != session.UserID {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
updatedConfig, err := a.app.UpdateUserConfig(userID, *patch)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
data, err := json.Marshal(updatedConfig)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
jsonBytesResponse(w, http.StatusOK, data)
auditRec.Success()
}
func (a *API) handleGetUser(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /api/v1/users/{userID} getUser
//
@ -1695,6 +1755,47 @@ func (a *API) handleGetSubscriptions(w http.ResponseWriter, r *http.Request) {
auditRec.Success()
}
func (a *API) handleOnboard(w http.ResponseWriter, r *http.Request) {
// swagger:operation POST /api/v1/onboard onboard
//
// Onboards a user on Boards.
//
// ---
// produces:
// - application/json
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// schema:
// "$ref": "#/definitions/OnboardingResponse"
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
ctx := r.Context()
session := ctx.Value(sessionContextKey).(*model.Session)
workspaceID, boardID, err := a.app.PrepareOnboardingTour(session.UserID)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
response := map[string]string{
"workspaceID": workspaceID,
"boardID": boardID,
}
data, err := json.Marshal(response)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
jsonBytesResponse(w, http.StatusOK, data)
}
// Response helpers
func (a *API) errorResponse(w http.ResponseWriter, api string, code int, message string, sourceError error) {

109
server/app/onboarding.go Normal file
View File

@ -0,0 +1,109 @@
package app
import (
"errors"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/services/store"
)
const (
KeyPrefix = "focalboard_" // use key prefix to namespace focalboard props
KeyOnboardingTourStarted = KeyPrefix + "onboardingTourStarted"
KeyOnboardingTourCategory = KeyPrefix + "tourCategory"
KeyOnboardingTourStep = KeyPrefix + "onboardingTourStep"
ValueOnboardingFirstStep = "0"
ValueTourCategoryOnboarding = "onboarding"
WelcomeBoardTitle = "Welcome to Boards!"
)
var (
errUnableToFindWelcomeBoard = errors.New("unable to find welcome board in newly created blocks")
)
func (a *App) PrepareOnboardingTour(userID string) (string, string, error) {
// create a private workspace for the user
workspaceID, err := a.store.CreatePrivateWorkspace(userID)
if err != nil {
return "", "", err
}
// copy the welcome board into this workspace
boardID, err := a.createWelcomeBoard(userID, workspaceID)
if err != nil {
return "", "", err
}
// set user's tour state to initial state
userPropPatch := model.UserPropPatch{
UpdatedFields: map[string]string{
KeyOnboardingTourStarted: "1",
KeyOnboardingTourStep: ValueOnboardingFirstStep,
KeyOnboardingTourCategory: ValueTourCategoryOnboarding,
},
}
if err := a.store.PatchUserProps(userID, userPropPatch); err != nil {
return "", "", err
}
return workspaceID, boardID, nil
}
func (a *App) getOnboardingBoardID() (string, error) {
blocks, err := a.store.GetDefaultTemplateBlocks()
if err != nil {
return "", err
}
var onboardingBoardID string
for _, block := range blocks {
if block.Type == model.TypeBoard && block.Title == WelcomeBoardTitle {
onboardingBoardID = block.ID
break
}
}
if onboardingBoardID == "" {
return "", errUnableToFindWelcomeBoard
}
return onboardingBoardID, nil
}
func (a *App) createWelcomeBoard(userID, workspaceID string) (string, error) {
onboardingBoardID, err := a.getOnboardingBoardID()
if err != nil {
return "", err
}
blocks, err := a.GetSubTree(store.Container{WorkspaceID: "0"}, onboardingBoardID, 3)
if err != nil {
return "", err
}
blocks = model.GenerateBlockIDs(blocks, a.logger)
// we're copying from a global template, so we need to set the
// `isTemplate` flag to false on the board
var welcomeBoardID string
for i := range blocks {
if blocks[i].Type == model.TypeBoard {
blocks[i].Fields["isTemplate"] = false
if blocks[i].Title == WelcomeBoardTitle {
welcomeBoardID = blocks[i].ID
break
}
}
}
model.StampModificationMetadata(userID, blocks, nil)
_, err = a.InsertBlocks(store.Container{WorkspaceID: workspaceID}, blocks, userID, false)
if err != nil {
return "", err
}
return welcomeBoardID, nil
}

View File

@ -0,0 +1,214 @@
package app
import (
"testing"
"github.com/golang/mock/gomock"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/services/store"
"github.com/stretchr/testify/assert"
)
func TestPrepareOnboardingTour(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
welcomeBoard := model.Block{
ID: "block_id_1",
Type: model.TypeBoard,
Title: "Welcome to Boards!",
Fields: map[string]interface{}{
"isTemplate": true,
},
}
blocks := []model.Block{welcomeBoard}
th.Store.EXPECT().GetDefaultTemplateBlocks().Return(blocks, nil)
th.Store.EXPECT().GetSubTree3(
store.Container{WorkspaceID: "0"},
"block_id_1",
gomock.Any(),
).Return([]model.Block{welcomeBoard}, nil)
th.Store.EXPECT().InsertBlock(
store.Container{WorkspaceID: "workspace_id_1"},
gomock.Any(),
"user_id_1",
).Return(nil)
th.Store.EXPECT().CreatePrivateWorkspace("user_id_1").Return("workspace_id_1", nil)
userPropPatch := model.UserPropPatch{
UpdatedFields: map[string]string{
KeyOnboardingTourStarted: "1",
KeyOnboardingTourStep: ValueOnboardingFirstStep,
KeyOnboardingTourCategory: ValueTourCategoryOnboarding,
},
}
th.Store.EXPECT().PatchUserProps("user_id_1", userPropPatch).Return(nil)
workspaceID, boardID, err := th.App.PrepareOnboardingTour("user_id_1")
assert.NoError(t, err)
assert.Equal(t, "workspace_id_1", workspaceID)
assert.NotEmpty(t, boardID)
})
}
func TestCreateWelcomeBoard(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
welcomeBoard := model.Block{
ID: "block_id_1",
Type: model.TypeBoard,
Title: "Welcome to Boards!",
Fields: map[string]interface{}{
"isTemplate": true,
},
}
blocks := []model.Block{welcomeBoard}
th.Store.EXPECT().GetDefaultTemplateBlocks().Return(blocks, nil)
th.Store.EXPECT().GetSubTree3(
store.Container{WorkspaceID: "0"},
"block_id_1",
gomock.Any(),
).Return([]model.Block{welcomeBoard}, nil)
th.Store.EXPECT().InsertBlock(
store.Container{WorkspaceID: "workspace_id_1"},
gomock.Any(),
"user_id_1",
).Return(nil)
boardID, err := th.App.createWelcomeBoard("user_id_1", "workspace_id_1")
assert.Nil(t, err)
assert.NotEmpty(t, boardID)
})
t.Run("template doesn't contain a board", func(t *testing.T) {
welcomeBoard := model.Block{
ID: "block_id_1",
Type: model.TypeComment,
Title: "Welcome to Boards!",
}
blocks := []model.Block{welcomeBoard}
th.Store.EXPECT().GetDefaultTemplateBlocks().Return(blocks, nil)
th.Store.EXPECT().GetSubTree3(
store.Container{WorkspaceID: "0"},
"buixxjic3xjfkieees4iafdrznc",
gomock.Any(),
).Return([]model.Block{welcomeBoard}, nil)
th.Store.EXPECT().InsertBlock(
store.Container{WorkspaceID: "workspace_id_1"},
gomock.Any(),
"user_id_1",
).Return(nil)
boardID, err := th.App.createWelcomeBoard("user_id_1", "workspace_id_1")
assert.Error(t, err)
assert.Empty(t, boardID)
})
t.Run("template doesn't contain the welcome board", func(t *testing.T) {
welcomeBoard := model.Block{
ID: "block_id_1",
Type: model.TypeBoard,
Title: "Jean luc Picard",
Fields: map[string]interface{}{
"isTemplate": true,
},
}
blocks := []model.Block{welcomeBoard}
th.Store.EXPECT().GetDefaultTemplateBlocks().Return(blocks, nil)
th.Store.EXPECT().GetSubTree3(
store.Container{WorkspaceID: "0"},
"buixxjic3xjfkieees4iafdrznc",
gomock.Any(),
).Return([]model.Block{welcomeBoard}, nil)
th.Store.EXPECT().InsertBlock(
store.Container{WorkspaceID: "workspace_id_1"},
gomock.Any(),
"user_id_1",
).Return(nil)
boardID, err := th.App.createWelcomeBoard("user_id_1", "workspace_id_1")
assert.Error(t, err)
assert.Empty(t, boardID)
})
}
func TestGetOnboardingBoardID(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
board := model.Block{
ID: "board_id_1",
Type: model.TypeBoard,
Title: "Welcome to Boards!",
}
card := model.Block{
ID: "card_id_1",
Type: model.TypeCard,
ParentID: board.ID,
}
blocks := []model.Block{
board,
card,
}
th.Store.EXPECT().GetDefaultTemplateBlocks().Return(blocks, nil)
onboardingBoardID, err := th.App.getOnboardingBoardID()
assert.NoError(t, err)
assert.Equal(t, "board_id_1", onboardingBoardID)
})
t.Run("no blocks found", func(t *testing.T) {
blocks := []model.Block{}
th.Store.EXPECT().GetDefaultTemplateBlocks().Return(blocks, nil)
onboardingBoardID, err := th.App.getOnboardingBoardID()
assert.Error(t, err)
assert.Empty(t, onboardingBoardID)
})
t.Run("onboarding board doesn't exists", func(t *testing.T) {
board := model.Block{
ID: "board_id_1",
Type: model.TypeBoard,
Title: "Some board title",
}
card := model.Block{
ID: "card_id_1",
Type: model.TypeCard,
ParentID: board.ID,
}
blocks := []model.Block{
board,
card,
}
th.Store.EXPECT().GetDefaultTemplateBlocks().Return(blocks, nil)
onboardingBoardID, err := th.App.getOnboardingBoardID()
assert.Error(t, err)
assert.Empty(t, onboardingBoardID)
})
}

View File

@ -5,3 +5,16 @@ import "github.com/mattermost/focalboard/server/model"
func (a *App) GetWorkspaceUsers(workspaceID string) ([]*model.User, error) {
return a.store.GetUsersByWorkspace(workspaceID)
}
func (a *App) UpdateUserConfig(userID string, patch model.UserPropPatch) (map[string]interface{}, error) {
if err := a.store.PatchUserProps(userID, patch); err != nil {
return nil, err
}
user, err := a.store.GetUserByID(userID)
if err != nil {
return nil, err
}
return user.Props, nil
}

View File

@ -3,6 +3,9 @@ package model
import (
"encoding/json"
"io"
"strconv"
"github.com/mattermost/focalboard/server/services/audit"
)
// Block is the basic data unit
@ -177,3 +180,19 @@ type QueryBlockHistoryOptions struct {
Limit uint64 // if non-zero then limit the number of returned records
Descending bool // if true then the records are sorted by insert_at in descending order
}
func StampModificationMetadata(userID string, blocks []Block, auditRec *audit.Record) {
if userID == SingleUser {
userID = ""
}
now := GetMillis()
for i := range blocks {
blocks[i].ModifiedBy = userID
blocks[i].UpdateAt = now
if auditRec != nil {
auditRec.AddMeta("block_"+strconv.FormatInt(int64(i), 10), blocks[i])
}
}
}

View File

@ -3,6 +3,8 @@ package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
"github.com/mattermost/focalboard/server/utils"
@ -252,3 +254,16 @@ func TestGenerateBlockIDs(t *testing.T) {
require.Equal(t, blocks[2].ID, block4ContentOrder[1].([]interface{})[1])
})
}
func TestStampModificationMetadata(t *testing.T) {
t.Run("base case", func(t *testing.T) {
block := Block{}
blocks := []Block{block}
assert.Empty(t, block.ModifiedBy)
assert.Empty(t, block.UpdateAt)
StampModificationMetadata("user_id_1", blocks, nil)
assert.Equal(t, "user_id_1", blocks[0].ModifiedBy)
assert.NotEmpty(t, blocks[0].UpdateAt)
})
}

View File

@ -57,6 +57,16 @@ type User struct {
IsBot bool `json:"is_bot"`
}
type UserPropPatch struct {
// The user prop updated fields
// required: false
UpdatedFields map[string]string `json:"updatedFields"`
// The user prop removed fields
// required: false
DeletedFields []string `json:"deletedFields"`
}
type Session struct {
ID string `json:"id"`
Token string `json:"token"`

View File

@ -25,7 +25,6 @@ import (
"github.com/mattermost/focalboard/server/services/notify/notifylogger"
"github.com/mattermost/focalboard/server/services/scheduler"
"github.com/mattermost/focalboard/server/services/store"
"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer"
"github.com/mattermost/focalboard/server/services/store/sqlstore"
"github.com/mattermost/focalboard/server/services/telemetry"
"github.com/mattermost/focalboard/server/services/webhook"
@ -233,13 +232,6 @@ func NewStore(config *config.Configuration, logger *mlog.Logger) (store.Store, e
if err != nil {
return nil, err
}
if config.AuthMode == MattermostAuthMod {
layeredStore, err2 := mattermostauthlayer.New(config.DBType, db.(*sqlstore.SQLStore).DBHandle(), db, logger)
if err2 != nil {
return nil, err2
}
db = layeredStore
}
return db, nil
}

View File

@ -6,6 +6,8 @@ import (
"fmt"
"strings"
"github.com/mattermost/mattermost-server/v6/plugin"
"github.com/pkg/errors"
sq "github.com/Masterminds/squirrel"
@ -40,18 +42,20 @@ func (pe NotSupportedError) Error() string {
// Store represents the abstraction of the data storage.
type MattermostAuthLayer struct {
store.Store
dbType string
mmDB *sql.DB
logger *mlog.Logger
dbType string
mmDB *sql.DB
logger *mlog.Logger
pluginAPI plugin.API
}
// New creates a new SQL implementation of the store.
func New(dbType string, db *sql.DB, store store.Store, logger *mlog.Logger) (*MattermostAuthLayer, error) {
func New(dbType string, db *sql.DB, store store.Store, logger *mlog.Logger, pluginAPI plugin.API) (*MattermostAuthLayer, error) {
layer := &MattermostAuthLayer{
Store: store,
dbType: dbType,
mmDB: db,
logger: logger,
Store: store,
dbType: dbType,
mmDB: db,
logger: logger,
pluginAPI: pluginAPI,
}
return layer, nil
@ -156,6 +160,33 @@ func (s *MattermostAuthLayer) UpdateUserPasswordByID(userID, password string) er
return NotSupportedError{"no update allowed from focalboard, update it using mattermost"}
}
func (s *MattermostAuthLayer) PatchUserProps(userID string, patch model.UserPropPatch) error {
user, err := s.pluginAPI.GetUser(userID)
if err != nil {
s.logger.Error("failed to fetch user", mlog.String("userID", userID), mlog.Err(err))
return err
}
props := user.Props
for _, key := range patch.DeletedFields {
delete(props, key)
}
for key, value := range patch.UpdatedFields {
props[key] = value
}
user.Props = props
if _, err := s.pluginAPI.UpdateUser(user); err != nil {
s.logger.Error("failed to update user", mlog.String("userID", userID), mlog.Err(err))
return err
}
return nil
}
// GetActiveUserCount returns the number of users with active sessions within N seconds ago.
func (s *MattermostAuthLayer) GetActiveUserCount(updatedSecondsAgo int64) (int, error) {
query := s.getQueryBuilder().
@ -456,3 +487,15 @@ func (s *MattermostAuthLayer) userWorkspacesFromRows(rows *sql.Rows) ([]model.Us
return userWorkspaces, nil
}
func (s *MattermostAuthLayer) CreatePrivateWorkspace(userID string) (string, error) {
// we emulate a private workspace by creating
// a DM channel from the user to themselves.
channel, err := s.pluginAPI.GetDirectChannel(userID, userID)
if err != nil {
s.logger.Error("error fetching private workspace", mlog.String("userID", userID), mlog.Err(err))
return "", err
}
return channel.Id, nil
}

View File

@ -50,6 +50,21 @@ func (mr *MockStoreMockRecorder) CleanUpSessions(arg0 interface{}) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanUpSessions", reflect.TypeOf((*MockStore)(nil).CleanUpSessions), arg0)
}
// CreatePrivateWorkspace mocks base method.
func (m *MockStore) CreatePrivateWorkspace(arg0 string) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreatePrivateWorkspace", arg0)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CreatePrivateWorkspace indicates an expected call of CreatePrivateWorkspace.
func (mr *MockStoreMockRecorder) CreatePrivateWorkspace(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePrivateWorkspace", reflect.TypeOf((*MockStore)(nil).CreatePrivateWorkspace), arg0)
}
// CreateSession mocks base method.
func (m *MockStore) CreateSession(arg0 *model.Session) error {
m.ctrl.T.Helper()
@ -760,6 +775,20 @@ func (mr *MockStoreMockRecorder) PatchBlocks(arg0, arg1, arg2 interface{}) *gomo
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchBlocks", reflect.TypeOf((*MockStore)(nil).PatchBlocks), arg0, arg1, arg2)
}
// PatchUserProps mocks base method.
func (m *MockStore) PatchUserProps(arg0 string, arg1 model.UserPropPatch) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PatchUserProps", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// PatchUserProps indicates an expected call of PatchUserProps.
func (mr *MockStoreMockRecorder) PatchUserProps(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchUserProps", reflect.TypeOf((*MockStore)(nil).PatchUserProps), arg0, arg1)
}
// RefreshSession mocks base method.
func (m *MockStore) RefreshSession(arg0 *model.Session) error {
m.ctrl.T.Helper()

View File

@ -27,6 +27,11 @@ func (s *SQLStore) CleanUpSessions(expireTime int64) error {
}
func (s *SQLStore) CreatePrivateWorkspace(userID string) (string, error) {
return s.createPrivateWorkspace(s.db, userID)
}
func (s *SQLStore) CreateSession(session *model.Session) error {
return s.createSession(s.db, session)
@ -352,6 +357,11 @@ func (s *SQLStore) PatchBlocks(c store.Container, blockPatches *model.BlockPatch
}
func (s *SQLStore) PatchUserProps(userID string, patch model.UserPropPatch) error {
return s.patchUserProps(s.db, userID, patch)
}
func (s *SQLStore) RefreshSession(session *model.Session) error {
return s.refreshSession(s.db, session)

View File

@ -234,3 +234,24 @@ func (s *SQLStore) usersFromRows(rows *sql.Rows) ([]*model.User, error) {
return users, nil
}
func (s *SQLStore) patchUserProps(db sq.BaseRunner, userID string, patch model.UserPropPatch) error {
user, err := s.getUserByID(db, userID)
if err != nil {
return err
}
if user.Props == nil {
user.Props = map[string]interface{}{}
}
for _, key := range patch.DeletedFields {
delete(user.Props, key)
}
for key, value := range patch.UpdatedFields {
user.Props[key] = value
}
return s.updateUser(db, user)
}

View File

@ -154,3 +154,9 @@ func (s *SQLStore) getWorkspaceCount(db sq.BaseRunner) (int64, error) {
func (s *SQLStore) getUserWorkspaces(_ sq.BaseRunner, _ string) ([]model.UserWorkspace, error) {
return nil, fmt.Errorf("GetUserWorkspaces %w", errUnsupportedOperation)
}
func (s *SQLStore) createPrivateWorkspace(_ sq.BaseRunner, _ string) (string, error) {
// for personal server we always have only
// a single workspace, with id "0".
return "0", nil
}

View File

@ -60,6 +60,7 @@ type Store interface {
UpdateUserPassword(username, password string) error
UpdateUserPasswordByID(userID, password string) error
GetUsersByWorkspace(workspaceID string) ([]*model.User, error)
PatchUserProps(userID string, patch model.UserPropPatch) error
GetActiveUserCount(updatedSecondsAgo int64) (int, error)
GetSession(token string, expireTime int64) (*model.Session, error)
@ -78,6 +79,7 @@ type Store interface {
HasWorkspaceAccess(userID string, workspaceID string) (bool, error)
GetWorkspaceCount() (int64, error)
GetUserWorkspaces(userID string) ([]model.UserWorkspace, error)
CreatePrivateWorkspace(userID string) (string, error)
CreateSubscription(c Container, sub *model.Subscription) (*model.Subscription, error)
DeleteSubscription(c Container, blockID string, subscriberID string) error

View File

@ -39,6 +39,11 @@ func StoreTestUserStore(t *testing.T, setup func(t *testing.T) (store.Store, fun
defer tearDown()
testCreateAndGetRegisteredUserCount(t, store)
})
t.Run("TestPatchUserProps", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
testPatchUserProps(t, store)
})
}
func testGetWorkspaceUsers(t *testing.T, store store.Store) {
@ -164,3 +169,61 @@ func testCreateAndGetRegisteredUserCount(t *testing.T, store store.Store) {
require.NoError(t, err)
require.Equal(t, randomN, got)
}
func testPatchUserProps(t *testing.T, store store.Store) {
user := &model.User{
ID: utils.NewID(utils.IDTypeUser),
}
err := store.CreateUser(user)
require.NoError(t, err)
// Only update props
patch := model.UserPropPatch{
UpdatedFields: map[string]string{
"new_key_1": "new_value_1",
"new_key_2": "new_value_2",
"new_key_3": "new_value_3",
},
}
err = store.PatchUserProps(user.ID, patch)
require.NoError(t, err)
fetchedUser, err := store.GetUserByID(user.ID)
require.NoError(t, err)
require.Equal(t, fetchedUser.Props["new_key_1"], "new_value_1")
require.Equal(t, fetchedUser.Props["new_key_2"], "new_value_2")
require.Equal(t, fetchedUser.Props["new_key_3"], "new_value_3")
// Delete a prop
patch = model.UserPropPatch{
DeletedFields: []string{
"new_key_1",
},
}
err = store.PatchUserProps(user.ID, patch)
require.NoError(t, err)
fetchedUser, err = store.GetUserByID(user.ID)
require.NoError(t, err)
_, ok := fetchedUser.Props["new_key_1"]
require.False(t, ok)
require.Equal(t, fetchedUser.Props["new_key_2"], "new_value_2")
require.Equal(t, fetchedUser.Props["new_key_3"], "new_value_3")
// update and delete together
patch = model.UserPropPatch{
UpdatedFields: map[string]string{
"new_key_3": "new_value_3_new_again",
},
DeletedFields: []string{
"new_key_2",
},
}
err = store.PatchUserProps(user.ID, patch)
require.NoError(t, err)
fetchedUser, err = store.GetUserByID(user.ID)
require.NoError(t, err)
_, ok = fetchedUser.Props["new_key_2"]
require.False(t, ok)
require.Equal(t, fetchedUser.Props["new_key_3"], "new_value_3_new_again")
}

View File

@ -109,6 +109,23 @@ definitions:
x-go-name: UpdatedFields
type: object
x-go-package: github.com/mattermost/focalboard/server/model
UserPropPatch:
description: UserConfigPatch is a patch for user config
properties:
deletedFields:
description: The config fields removed
items:
type: string
type: array
x-go-name: DeletedFields
updatedFields:
additionalProperties:
type: object
description: The updated config
type: object
x-go-name: UpdatedFields
type: object
x-go-package: github.com/mattermost/focalboard/server/model
BlockPatchBatch:
description: BlockPatchBatch is a batch of IDs and patches for modify blocks
properties:
@ -476,6 +493,15 @@ definitions:
- updateAt
type: object
x-go-package: github.com/mattermost/focalboard/server/model
OnboardingResponse:
description: OnboardResponse contains basic data required by the client to complete the onboarding
properties:
workspaceID:
description: the workspace to send to user to, to start the onboarding tour
type: string
boardID:
description: the board to send to user to, to start the onboarding tour
type: string
host: localhost
info:
contact:

View File

@ -17,6 +17,8 @@ declare namespace Cypress {
apiInitServer: () => Chainable
apiDeleteBlock: (id: string) => Chainable
apiResetBoards: () => Chainable
apiSkipTour: (userID: string) => Chainable
uiCreateNewBoard: (title?: string) => Chainable
uiAddNewGroup: (name?: string) => Chainable
uiAddNewCard: (title?: string, columnIndex?: number) => Chainable

View File

@ -5,6 +5,7 @@ describe('Card badges', () => {
beforeEach(() => {
cy.apiInitServer()
cy.apiResetBoards()
cy.apiGetMe().then((userID) => cy.apiSkipTour(userID))
localStorage.setItem('welcomePageViewed', 'true')
})

View File

@ -5,6 +5,7 @@ describe('Card URL Property', () => {
beforeEach(() => {
cy.apiInitServer()
cy.apiResetBoards()
cy.apiGetMe().then((userID) => cy.apiSkipTour(userID))
localStorage.setItem('welcomePageViewed', 'true')
})

View File

@ -9,6 +9,7 @@ describe('Create and delete board / card', () => {
beforeEach(() => {
cy.apiInitServer()
cy.apiResetBoards()
cy.apiGetMe().then((userID) => cy.apiSkipTour(userID))
localStorage.setItem('welcomePageViewed', 'true')
})
@ -59,8 +60,8 @@ describe('Create and delete board / card', () => {
// Rename board view
cy.log('**Rename board view**')
const boardViewTitle = `Test board (${timestamp})`
cy.get(".ViewHeader>.Editable[title='Board view']").should('exist')
cy.get('.ViewHeader>.Editable').
cy.get(".ViewHeader>.viewSelector>.Editable[title='Board view']").should('exist')
cy.get('.ViewHeader>.viewSelector>.Editable').
clear().
type(boardViewTitle).
type('{esc}')

View File

@ -5,6 +5,7 @@ describe('Group board by different properties', () => {
beforeEach(() => {
cy.apiInitServer()
cy.apiResetBoards()
cy.apiGetMe().then((userID) => cy.apiSkipTour(userID))
localStorage.setItem('welcomePageViewed', 'true')
})

View File

@ -27,6 +27,8 @@ describe('Login actions', () => {
cy.get('#login-username').type(username)
cy.get('#login-password').type(password)
cy.get('button').contains('Register').click()
cy.location('pathname', {timeout: 10000}).should('include', '/welcome')
cy.get('a').contains('No thanks').click()
workspaceIsAvailable()
// Can log out user
@ -94,6 +96,8 @@ describe('Login actions', () => {
cy.get('#login-username').type('new-user')
cy.get('#login-password').type('new-password')
cy.get('button').contains('Register').click()
cy.location('pathname', {timeout: 10000}).should('include', '/welcome')
cy.get('a').contains('No thanks').click()
workspaceIsAvailable()
})
})

View File

@ -5,6 +5,7 @@ describe('Manage groups', () => {
beforeEach(() => {
cy.apiInitServer()
cy.apiResetBoards()
cy.apiGetMe().then((userID) => cy.apiSkipTour(userID))
localStorage.setItem('welcomePageViewed', 'true')
})

View File

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {Board} from '../../src/blocks/board'
import {UserConfigPatch} from '../../src/user'
Cypress.Commands.add('apiRegisterUser', (data: Cypress.UserData, token?: string, failOnError?: boolean) => {
return cy.request({
@ -81,6 +82,21 @@ Cypress.Commands.add('apiResetBoards', () => {
})
})
Cypress.Commands.add('apiSkipTour', (userID: string) => {
const body: UserConfigPatch = {
updatedFields: {
focalboard_welcomePageViewed: '1',
},
}
return cy.request({
method: 'PUT',
url: `/api/v1/users/${encodeURIComponent(userID)}/config`,
...headers(),
body,
})
})
Cypress.Commands.add('apiGetMe', () => {
return cy.request({
method: 'GET',

View File

@ -246,7 +246,8 @@
"ViewTitle.show-description": "show description",
"ViewTitle.untitled-board": "Untitled board",
"WelcomePage.Description": "Boards is a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view",
"WelcomePage.Explore.Button": "Explore",
"WelcomePage.Explore.Button": "Take a tour",
"WelcomePage.NoThanks.Text": "No thanks, I'll figure it out myself",
"WelcomePage.Heading": "Welcome To Boards",
"Workspace.editing-board-template": "You're editing a board template.",
"calendar.month": "Month",
@ -260,5 +261,25 @@
"login.log-in-title": "Log in",
"login.register-button": "or create an account if you don't have one",
"register.login-button": "or log in if you already have an account",
"OnboardingTour.OpenACard.Title": "Open a card",
"OnboardingTour.OpenACard.Body": "Open a card to explore the powerful ways that Boards can help you organize your work.",
"OnboardingTour.AddProperties.Title": "Add properties",
"OnboardingTour.AddProperties.Body": "Add various properties to cards to make them more powerful!",
"OnboardingTour.AddComments.Title": "Add comments",
"OnboardingTour.AddComments.Body": "You can comment on issues, and even @mention your fellow Mattermost users to get their attention.",
"OnboardingTour.AddDescription.Title": "Add description",
"OnboardingTour.AddDescription.Body": "Add a description to your card so your teammates know what the card is about.",
"OnboardingTour.AddView.Title": "Add a new view",
"OnboardingTour.AddView.Body": "Go here to create a new view to organise your board using different layouts.",
"OnboardingTour.CopyLink.Title": "Copy link",
"OnboardingTour.CopyLink.Body": "You can share your cards with teammates by copying the link and pasting it in a channel, Direct Message, or Group Message.",
"OnboardingTour.ShareBoard.Title": "Share board",
"OnboardingTour.ShareBoard.Body": "You can share your board internally, within your team, or publish it publicly for visibility outside of your organization.",
"tutorial_tip.finish_tour": "Done",
"tutorial_tip.got_it": "Got it",
"tutorial_tip.ok": "Next",
"generic.previous": "Previous",
"tutorial_tip.seen": "Seen this before?",
"tutorial_tip.out": "Opt out of these tips",
"register.signup-title": "Sign up for your account"
}
}

1245
webapp/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,7 @@
"@fullcalendar/react": "^5.10.0",
"@mattermost/compass-icons": "^0.1.10",
"@reduxjs/toolkit": "^1.6.0",
"@tippyjs/react": "^4.2.6",
"color": "^4.0.0",
"draft-js": "^0.11.7",
"emoji-mart": "^3.0.1",

View File

@ -11,8 +11,7 @@ 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 {createBrowserHistory, History} from 'history'
import TelemetryClient from './telemetry/telemetryClient'
@ -35,57 +34,32 @@ import {setGlobalError, getGlobalError} from './store/globalError'
import {useAppSelector, useAppDispatch} from './store/hooks'
import {fetchClientConfig} from './store/clientConfig'
import {IUser} from './user'
import {UserSettings} from './userSettings'
import {IUser, UserPropPrefix} from './user'
import {UserSettingKey, UserSettings} from './userSettings'
declare let window: IAppWindow
const UUID_REGEX = new RegExp(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/)
const App = (): JSX.Element => {
type Props = {
history?: History<unknown>
}
const App = (props: Props): JSX.Element => {
const language = useAppSelector<string>(getLanguage)
const loggedIn = useAppSelector<boolean|null>(getLoggedIn)
const globalError = useAppSelector<string>(getGlobalError)
const me = useAppSelector<IUser|null>(getMe)
const dispatch = useAppDispatch()
const browserHistory: ReturnType<typeof createBrowserHistory> = useMemo(() => {
const history = createBrowserHistory({basename: Utils.getFrontendBaseURL()})
if (Utils.isDesktop() && Utils.isFocalboardPlugin()) {
window.addEventListener('message', (event: MessageEvent) => {
if (event.origin !== window.location.origin) {
return
}
const pathName = event.data.message?.pathName
if (!pathName || !pathName.startsWith(window.frontendBaseURL)) {
return
}
Utils.log(`Navigating Boards to ${pathName}`)
history.replace(pathName.replace(window.frontendBaseURL, ''))
})
}
return {
...history,
push: (path: string, state?: unknown) => {
if (Utils.isDesktop() && Utils.isFocalboardPlugin()) {
window.postMessage(
{
type: 'browser-history-push',
message: {
path: `${window.frontendBaseURL}${path}`,
},
},
window.location.origin,
)
} else {
history.push(path, state)
}
},
}
}, [])
let browserHistory: History<unknown>
if (props.history) {
browserHistory = props.history
} else {
browserHistory = useMemo(() => {
return createBrowserHistory({basename: Utils.getFrontendBaseURL()})
}, [])
}
useEffect(() => {
dispatch(fetchLanguage())
@ -128,7 +102,7 @@ const App = (): JSX.Element => {
}
const continueToWelcomeScreen = () => {
return Utils.isFocalboardPlugin() && loggedIn === true && !UserSettings.welcomePageViewed
return loggedIn === true && !me?.props[UserPropPrefix + UserSettingKey.WelcomePageViewed]
}
return (

File diff suppressed because it is too large Load Diff

View File

@ -364,43 +364,51 @@ exports[`src/components/workspace return workspace and showcard 1`] = `
</div>
</div>
<div
class="ShareBoardButton"
class="shareButtonWrapper"
>
<button
title="Share board"
type="button"
<div
class="ShareBoardButton"
>
<span>
<button
title="Share board"
type="button"
>
<i
class="CompassIcon icon-globe CompassIcon"
/>
Share
</span>
</button>
<span>
Share
</span>
</button>
</div>
</div>
</div>
<div
class="ViewHeader"
>
<input
class="Editable undefined"
placeholder="Untitled View"
spellcheck="true"
title="view title"
value="view title"
/>
<div
aria-label="View menu"
class="MenuWrapper"
role="button"
class="viewSelector"
>
<button
type="button"
<input
class="Editable undefined"
placeholder="Untitled View"
spellcheck="true"
title="view title"
value="view title"
/>
<div
aria-label="View menu"
class="MenuWrapper"
role="button"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</button>
<button
type="button"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</button>
</div>
</div>
<div
class="octo-spacer"
@ -635,13 +643,13 @@ exports[`src/components/workspace return workspace and showcard 1`] = `
class="octo-board-column"
>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -678,13 +686,13 @@ exports[`src/components/workspace return workspace and showcard 1`] = `
</div>
</div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -721,13 +729,13 @@ exports[`src/components/workspace return workspace and showcard 1`] = `
</div>
</div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -923,30 +931,37 @@ exports[`src/components/workspace return workspace readonly and showcard 1`] = `
</div>
</div>
</div>
<div
class="shareButtonWrapper"
/>
</div>
<div
class="ViewHeader"
>
<input
class="Editable readonly undefined"
placeholder="Untitled View"
readonly=""
spellcheck="true"
title="view title"
value="view title"
/>
<div
aria-label="View menu"
class="MenuWrapper"
role="button"
class="viewSelector"
>
<button
type="button"
<input
class="Editable readonly undefined"
placeholder="Untitled View"
readonly=""
spellcheck="true"
title="view title"
value="view title"
/>
<div
aria-label="View menu"
class="MenuWrapper"
role="button"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</button>
<button
type="button"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</button>
</div>
</div>
<div
class="octo-spacer"
@ -1037,7 +1052,7 @@ exports[`src/components/workspace return workspace readonly and showcard 1`] = `
class="octo-board-column"
>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="false"
style="opacity: 1;"
>
@ -1067,7 +1082,7 @@ exports[`src/components/workspace return workspace readonly and showcard 1`] = `
</div>
</div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="false"
style="opacity: 1;"
>
@ -1097,7 +1112,7 @@ exports[`src/components/workspace return workspace readonly and showcard 1`] = `
</div>
</div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="false"
style="opacity: 1;"
>
@ -1588,43 +1603,51 @@ exports[`src/components/workspace should match snapshot 1`] = `
</div>
</div>
<div
class="ShareBoardButton"
class="shareButtonWrapper"
>
<button
title="Share board"
type="button"
<div
class="ShareBoardButton"
>
<span>
<button
title="Share board"
type="button"
>
<i
class="CompassIcon icon-globe CompassIcon"
/>
Share
</span>
</button>
<span>
Share
</span>
</button>
</div>
</div>
</div>
<div
class="ViewHeader"
>
<input
class="Editable undefined"
placeholder="Untitled View"
spellcheck="true"
title="view title"
value="view title"
/>
<div
aria-label="View menu"
class="MenuWrapper"
role="button"
class="viewSelector"
>
<button
type="button"
<input
class="Editable undefined"
placeholder="Untitled View"
spellcheck="true"
title="view title"
value="view title"
/>
<div
aria-label="View menu"
class="MenuWrapper"
role="button"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</button>
<button
type="button"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</button>
</div>
</div>
<div
class="octo-spacer"
@ -1859,13 +1882,13 @@ exports[`src/components/workspace should match snapshot 1`] = `
class="octo-board-column"
>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -1902,13 +1925,13 @@ exports[`src/components/workspace should match snapshot 1`] = `
</div>
</div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -1945,13 +1968,13 @@ exports[`src/components/workspace should match snapshot 1`] = `
</div>
</div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -2147,30 +2170,37 @@ exports[`src/components/workspace should match snapshot with readonly 1`] = `
</div>
</div>
</div>
<div
class="shareButtonWrapper"
/>
</div>
<div
class="ViewHeader"
>
<input
class="Editable readonly undefined"
placeholder="Untitled View"
readonly=""
spellcheck="true"
title="view title"
value="view title"
/>
<div
aria-label="View menu"
class="MenuWrapper"
role="button"
class="viewSelector"
>
<button
type="button"
<input
class="Editable readonly undefined"
placeholder="Untitled View"
readonly=""
spellcheck="true"
title="view title"
value="view title"
/>
<div
aria-label="View menu"
class="MenuWrapper"
role="button"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</button>
<button
type="button"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</button>
</div>
</div>
<div
class="octo-spacer"
@ -2261,7 +2291,7 @@ exports[`src/components/workspace should match snapshot with readonly 1`] = `
class="octo-board-column"
>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="false"
style="opacity: 1;"
>
@ -2291,7 +2321,7 @@ exports[`src/components/workspace should match snapshot with readonly 1`] = `
</div>
</div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="false"
style="opacity: 1;"
>
@ -2321,7 +2351,7 @@ exports[`src/components/workspace should match snapshot with readonly 1`] = `
</div>
</div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="false"
style="opacity: 1;"
>
@ -2361,3 +2391,284 @@ exports[`src/components/workspace should match snapshot with readonly 1`] = `
</div>
</div>
`;
exports[`src/components/workspace show add new view tooltip 1`] = `
<div
class="tippy-box tutorial-tour-tip__box AddViewTourStep"
data-animation="scale-subtle"
data-escaped=""
data-placement="bottom-start"
data-reference-hidden=""
data-state="hidden"
role="tooltip"
style="max-width: 320px; transition-duration: 0ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="hidden"
style="transition-duration: 0ms;"
>
<div>
<div>
<div
class="tutorial-tour-tip__header"
>
<h4
class="tutorial-tour-tip__header__title"
>
Add a new view
</h4>
<button
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="tutorial-tour-tip__body"
>
Go here to create a new view to organise your board using different layouts.
</div>
<div
class="tutorial-tour-tip__footer"
>
<div
class="tutorial-tour-tip__footer-buttons"
>
<div
class="tutorial-tour-tip__circles-ctr"
>
<div
class="tutorial-tour-tip__circular-ring tutorial-tour-tip__circular-ring-active"
>
<a
class="tutorial-tour-tip__circle active"
data-screen="0"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="1"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="2"
href="#"
/>
</div>
</div>
<div
class="tutorial-tour-tip__btn-ctr"
>
<button
type="button"
>
<span>
Next
</span>
<i
class="CompassIcon icon-chevron-right icon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="tippy-arrow"
style="position: absolute; left: 0px; transform: translate(3px, 0px);"
/>
</div>
`;
exports[`src/components/workspace show copy link tooltip 1`] = `
<div
class="tippy-box tutorial-tour-tip__box CopyLinkTourStep"
data-animation="scale-subtle"
data-escaped=""
data-placement="right-start"
data-reference-hidden=""
data-state="visible"
role="tooltip"
style="max-width: 320px; transition-duration: 250ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="visible"
style="transition-duration: 250ms;"
>
<div>
<div>
<div
class="tutorial-tour-tip__header"
>
<h4
class="tutorial-tour-tip__header__title"
>
Copy link
</h4>
<button
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="tutorial-tour-tip__body"
>
You can share your cards with teammates by copying the link and pasting it in a channel, Direct Message, or Group Message.
</div>
<div
class="tutorial-tour-tip__footer"
>
<div
class="tutorial-tour-tip__footer-buttons"
>
<div
class="tutorial-tour-tip__circles-ctr"
>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="0"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring tutorial-tour-tip__circular-ring-active"
>
<a
class="tutorial-tour-tip__circle active"
data-screen="1"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="2"
href="#"
/>
</div>
</div>
<div
class="tutorial-tour-tip__btn-ctr"
>
<button
title="Previous"
type="button"
>
<i
class="CompassIcon icon-chevron-left icon"
/>
<span>
Previous
</span>
</button>
<button
type="button"
>
<span>
Next
</span>
<i
class="CompassIcon icon-chevron-right icon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="tippy-arrow"
style="position: absolute; top: 0px; transform: translate(0px, 3px);"
/>
</div>
`;
exports[`src/components/workspace show open card tooltip 1`] = `
<div
class="tippy-box tutorial-tour-tip__box OpenCardTourStep"
data-animation="scale-subtle"
data-escaped=""
data-placement="top"
data-reference-hidden=""
data-state="visible"
role="tooltip"
style="max-width: 320px; transition-duration: 250ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="visible"
style="transition-duration: 250ms;"
>
<div>
<div>
<div
class="tutorial-tour-tip__header"
>
<h4
class="tutorial-tour-tip__header__title"
>
Open a card
</h4>
<button
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="tutorial-tour-tip__body"
>
Open a card to explore the powerful ways that Boards can help you organize your work.
</div>
<div
class="tutorial-tour-tip__footer"
>
<div
class="tutorial-tour-tip__footer-buttons"
>
<div
class="tutorial-tour-tip__circles-ctr"
/>
<div
class="tutorial-tour-tip__btn-ctr"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="tippy-arrow"
style="position: absolute; left: 0px; transform: translate(3px, 0px);"
/>
</div>
`;

View File

@ -49,25 +49,29 @@ exports[`components/boardTemplateSelector/boardTemplateSelectorPreview should ma
<div
class="ViewHeader"
>
<input
class="Editable undefined"
placeholder="Untitled View"
spellcheck="true"
title="View"
value="View"
/>
<div
aria-label="View menu"
class="MenuWrapper"
role="button"
class="viewSelector"
>
<button
type="button"
<input
class="Editable undefined"
placeholder="Untitled View"
spellcheck="true"
title="View"
value="View"
/>
<div
aria-label="View menu"
class="MenuWrapper"
role="button"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</button>
<button
type="button"
>
<i
class="CompassIcon icon-chevron-down DropdownIcon"
/>
</button>
</div>
</div>
<div
class="octo-spacer"
@ -354,13 +358,13 @@ exports[`components/boardTemplateSelector/boardTemplateSelectorPreview should ma
class="octo-board-column"
>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button

View File

@ -103,8 +103,21 @@ describe('components/boardTemplateSelector/boardTemplateSelectorPreview', () =>
jest.clearAllMocks()
const state = {
searchText: {value: ''},
users: {me: {id: 'user-id'}},
cards: {templates: []},
users: {
me: {
id: 'user-id',
props: {
focalboard_onboardingTourStarted: false,
},
},
},
cards: {
templates: [],
cards: {
card_id_1: {title: 'Create a new card'},
},
current: 'card_id_1',
},
views: {views: []},
contents: {contents: []},
comments: {comments: []},

View File

@ -0,0 +1,362 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/cardDetail/CardDetail should show add comments tour tip 1`] = `
<div
class="tippy-box tutorial-tour-tip__box AddCommentTourStep"
data-animation="scale-subtle"
data-escaped=""
data-placement="right-end"
data-reference-hidden=""
data-state="visible"
role="tooltip"
style="max-width: 320px; transition-duration: 250ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="visible"
style="transition-duration: 250ms;"
>
<div>
<div>
<div
class="tutorial-tour-tip__header"
>
<h4
class="tutorial-tour-tip__header__title"
>
Add comments
</h4>
<button
class="IconButton tutorial-tour-tip__header__close size--small"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="tutorial-tour-tip__body"
>
You can comment on issues, and even @mention your fellow Mattermost users to get their attention.
</div>
<div
class="tutorial-tour-tip__image"
>
<img
alt="tutorial tour tip product image"
src="test-file-stub"
/>
</div>
<div
class="tutorial-tour-tip__footer"
>
<div
class="tutorial-tour-tip__footer-buttons"
>
<div
class="tutorial-tour-tip__circles-ctr"
>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="0"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring tutorial-tour-tip__circular-ring-active"
>
<a
class="tutorial-tour-tip__circle active"
data-screen="1"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="2"
href="#"
/>
</div>
</div>
<div
class="tutorial-tour-tip__btn-ctr"
>
<button
class="Button emphasis--tertiary size--small"
title="Previous"
type="button"
>
<i
class="CompassIcon icon-chevron-left icon"
/>
<span>
Previous
</span>
</button>
<button
class="Button filled size--small tipNextButton"
type="button"
>
<span>
Next
</span>
<i
class="CompassIcon icon-chevron-right icon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="tippy-arrow"
style="position: absolute; top: 0px; transform: translate(0px, 3px);"
/>
</div>
`;
exports[`components/cardDetail/CardDetail should show add description tour tip 1`] = `
<div
class="tippy-box tutorial-tour-tip__box AddDescriptionTourStep"
data-animation="scale-subtle"
data-escaped=""
data-placement="top-start"
data-reference-hidden=""
data-state="visible"
role="tooltip"
style="max-width: 320px; transition-duration: 250ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="visible"
style="transition-duration: 250ms;"
>
<div>
<div>
<div
class="tutorial-tour-tip__header"
>
<h4
class="tutorial-tour-tip__header__title"
>
Add description
</h4>
<button
class="IconButton tutorial-tour-tip__header__close size--small"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="tutorial-tour-tip__body"
>
Add a description to your card so your teammates know what the card is about.
</div>
<div
class="tutorial-tour-tip__image"
>
<img
alt="tutorial tour tip product image"
src="test-file-stub"
/>
</div>
<div
class="tutorial-tour-tip__footer"
>
<div
class="tutorial-tour-tip__footer-buttons"
>
<div
class="tutorial-tour-tip__circles-ctr"
>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="0"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="1"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring tutorial-tour-tip__circular-ring-active"
>
<a
class="tutorial-tour-tip__circle active"
data-screen="2"
href="#"
/>
</div>
</div>
<div
class="tutorial-tour-tip__btn-ctr"
>
<button
class="Button emphasis--tertiary size--small"
title="Previous"
type="button"
>
<i
class="CompassIcon icon-chevron-left icon"
/>
<span>
Previous
</span>
</button>
<button
class="Button filled size--small tipNextButton"
type="button"
>
<span>
Done
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="tippy-arrow"
style="position: absolute; left: 0px; transform: translate(3px, 0px);"
/>
</div>
`;
exports[`components/cardDetail/CardDetail should show add properties tour tip 1`] = `
<div
class="tippy-box tutorial-tour-tip__box AddPropertiesTourStep"
data-animation="scale-subtle"
data-escaped=""
data-placement="right-end"
data-reference-hidden=""
data-state="visible"
role="tooltip"
style="max-width: 320px; transition-duration: 250ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="visible"
style="transition-duration: 250ms;"
>
<div>
<div>
<div
class="tutorial-tour-tip__header"
>
<h4
class="tutorial-tour-tip__header__title"
>
Add properties
</h4>
<button
class="IconButton tutorial-tour-tip__header__close size--small"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="tutorial-tour-tip__body"
>
Add various properties to cards to make them more powerful!
</div>
<div
class="tutorial-tour-tip__image"
>
<img
alt="tutorial tour tip product image"
src="test-file-stub"
/>
</div>
<div
class="tutorial-tour-tip__footer"
>
<div
class="tutorial-tour-tip__footer-buttons"
>
<div
class="tutorial-tour-tip__circles-ctr"
>
<div
class="tutorial-tour-tip__circular-ring tutorial-tour-tip__circular-ring-active"
>
<a
class="tutorial-tour-tip__circle active"
data-screen="0"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="1"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="2"
href="#"
/>
</div>
</div>
<div
class="tutorial-tour-tip__btn-ctr"
>
<button
class="Button filled size--small tipNextButton"
type="button"
>
<span>
Next
</span>
<i
class="CompassIcon icon-chevron-right icon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="tippy-arrow"
style="position: absolute; top: 0px; transform: translate(0px, 3px);"
/>
</div>
`;

View File

@ -118,10 +118,13 @@
&.add-property {
margin: 8px 0 0;
color: rgba(var(--center-channel-color-rgb), 0.4);
position: relative;
}
}
&.content-blocks {
position: relative;
&:hover,
&:focus-within {
.CardDetailContentsMenu {

View File

@ -8,14 +8,25 @@ import {act, render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import {Provider as ReduxProvider} from 'react-redux'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {FetchMock} from '../../test/fetchMock'
import {TestBlockFactory} from '../../test/testBlockFactory'
import {mockDOM, wrapIntl} from '../../testUtils'
import {mockDOM, wrapDNDIntl, wrapIntl} from '../../testUtils'
import octoClient from '../../octoClient'
import {createTextBlock} from '../../blocks/textBlock'
import CardDetail from './cardDetail'
global.fetch = FetchMock.fn
jest.mock('../../octoClient')
const mockedOctoClient = mocked(octoClient, true)
beforeEach(() => {
FetchMock.fn.mockReset()
@ -61,6 +72,18 @@ describe('components/cardDetail/CardDetail', () => {
{username: 'username_1'},
],
},
boards: {
boards: {
[board.id]: board,
},
current: board.id,
},
cards: {
cards: {
[card.id]: card,
},
current: card.id,
},
})
const component = (
@ -142,4 +165,290 @@ describe('components/cardDetail/CardDetail', () => {
const newCommentSection = container!.querySelectorAll('.newcomment')
expect(newCommentSection.length).toBe(0)
})
test('should show add properties tour tip', async () => {
const mockStore = configureStore([])
const welcomeBoard = TestBlockFactory.createBoard()
welcomeBoard.title = 'Welcome to Boards!'
const welcomeCard = TestBlockFactory.createCard(welcomeBoard)
welcomeCard.title = 'Create a new card'
const store = mockStore({
users: {
me: {
id: 'user_id_1',
props: {
focalboard_welcomePageViewed: '1',
focalboard_onboardingTourStarted: true,
focalboard_tourCategory: 'card',
focalboard_onboardingTourStep: '0',
},
},
workspaceUsers: [
{username: 'username_1'},
],
},
boards: {
boards: {
[welcomeBoard.id]: welcomeBoard,
},
current: welcomeBoard.id,
},
cards: {
cards: {
[welcomeCard.id]: welcomeCard,
},
current: welcomeCard.id,
},
})
const onboardingBoard = TestBlockFactory.createBoard()
onboardingBoard.title = 'Welcome to Boards!'
const onboardingCard = TestBlockFactory.createCard(board)
onboardingCard.title = 'Create a new card'
const component = (
<ReduxProvider store={store}>
{wrapIntl(
<CardDetail
board={onboardingBoard}
activeView={view}
views={[view]}
cards={[onboardingCard]}
card={onboardingCard}
comments={[comment1, comment2]}
contents={[]}
readonly={false}
/>,
)}
</ReduxProvider>
)
let container: Element | DocumentFragment | null = null
await act(async () => {
const result = render(component)
container = result.container
})
expect(container).toBeDefined()
expect(container).not.toBeNull()
const tourTip = document.querySelectorAll('.AddPropertiesTourStep')
expect(tourTip.length).toBe(2)
expect(tourTip[1]).toMatchSnapshot()
// moving to next step
mockedOctoClient.patchUserConfig.mockResolvedValueOnce({})
const nextBtn = document!.querySelector('.tipNextButton')
expect(nextBtn).toBeDefined()
expect(nextBtn).not.toBeNull()
await act(async () => {
userEvent.click(nextBtn!)
})
expect(mockedOctoClient.patchUserConfig).toBeCalledWith(
'user_id_1',
{
updatedFields: {
focalboard_onboardingTourStep: '1',
},
},
)
})
test('should show add comments tour tip', async () => {
const mockStore = configureStore([])
const welcomeBoard = TestBlockFactory.createBoard()
welcomeBoard.title = 'Welcome to Boards!'
const welcomeCard = TestBlockFactory.createCard(welcomeBoard)
welcomeCard.title = 'Create a new card'
const store = mockStore({
users: {
me: {
id: 'user_id_1',
props: {
focalboard_welcomePageViewed: '1',
focalboard_onboardingTourStarted: true,
focalboard_tourCategory: 'card',
focalboard_onboardingTourStep: '1',
},
},
workspaceUsers: [
{username: 'username_1'},
],
},
boards: {
boards: {
[welcomeBoard.id]: welcomeBoard,
},
current: welcomeBoard.id,
},
cards: {
cards: {
[welcomeCard.id]: welcomeCard,
},
current: welcomeCard.id,
},
})
const onboardingBoard = TestBlockFactory.createBoard()
onboardingBoard.title = 'Welcome to Boards!'
const onboardingCard = TestBlockFactory.createCard(board)
onboardingCard.title = 'Create a new card'
const component = (
<ReduxProvider store={store}>
{wrapIntl(
<CardDetail
board={onboardingBoard}
activeView={view}
views={[view]}
cards={[onboardingCard]}
card={onboardingCard}
comments={[comment1, comment2]}
contents={[]}
readonly={false}
/>,
)}
</ReduxProvider>
)
let container: Element | DocumentFragment | null = null
await act(async () => {
const result = render(component)
container = result.container
})
expect(container).toBeDefined()
expect(container).not.toBeNull()
const tourTip = document.querySelectorAll('.AddCommentTourStep')
expect(tourTip.length).toBe(2)
expect(tourTip[1]).toMatchSnapshot()
// moving to next step
mockedOctoClient.patchUserConfig.mockResolvedValueOnce({})
const nextBtn = document!.querySelector('.tipNextButton')
expect(nextBtn).toBeDefined()
expect(nextBtn).not.toBeNull()
await act(async () => {
userEvent.click(nextBtn!)
})
expect(mockedOctoClient.patchUserConfig).toBeCalledWith(
'user_id_1',
{
updatedFields: {
focalboard_onboardingTourStep: '2',
},
},
)
})
test('should show add description tour tip', async () => {
const mockStore = configureStore([])
const welcomeBoard = TestBlockFactory.createBoard()
welcomeBoard.title = 'Welcome to Boards!'
const welcomeCard = TestBlockFactory.createCard(welcomeBoard)
welcomeCard.title = 'Create a new card'
const state = {
users: {
me: {
id: 'user_id_1',
props: {
focalboard_welcomePageViewed: '1',
focalboard_onboardingTourStarted: true,
focalboard_tourCategory: 'card',
focalboard_onboardingTourStep: '2',
},
},
workspaceUsers: [
{username: 'username_1'},
],
},
boards: {
boards: {
[welcomeBoard.id]: welcomeBoard,
},
current: welcomeBoard.id,
},
cards: {
cards: {
[welcomeCard.id]: welcomeCard,
},
current: welcomeCard.id,
},
}
const store = mockStore(state)
const onboardingBoard = TestBlockFactory.createBoard()
onboardingBoard.title = 'Welcome to Boards!'
const onboardingCard = TestBlockFactory.createCard(board)
onboardingCard.title = 'Create a new card'
const text = createTextBlock()
text.title = 'description'
text.parentId = onboardingCard.id
onboardingCard.fields.contentOrder = [text.id]
const component = (
<ReduxProvider store={store}>
{wrapDNDIntl(
<CardDetail
board={onboardingBoard}
activeView={view}
views={[view]}
cards={[onboardingCard]}
card={onboardingCard}
comments={[comment1, comment2]}
contents={[text]}
readonly={false}
/>,
)}
</ReduxProvider>
)
let container: Element | DocumentFragment | null = null
await act(async () => {
const result = render(component)
container = result.container
})
expect(container).toBeDefined()
expect(container).not.toBeNull()
const tourTip = document.querySelectorAll('.AddDescriptionTourStep')
expect(tourTip.length).toBe(2)
expect(tourTip[1]).toMatchSnapshot()
// moving to next step
mockedOctoClient.patchUserConfig.mockResolvedValueOnce({})
const nextBtn = document!.querySelector('.tipNextButton')
expect(nextBtn).toBeDefined()
expect(nextBtn).not.toBeNull()
await act(async () => {
userEvent.click(nextBtn!)
})
expect(mockedOctoClient.patchUserConfig).toBeCalledWith(
'user_id_1',
{
updatedFields: {
focalboard_onboardingTourStep: '999',
},
},
)
})
})

View File

@ -18,6 +18,9 @@ import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../teleme
import BlockIconSelector from '../blockIconSelector'
import {useAppDispatch} from '../../store/hooks'
import {setCurrent as setCurrentCard} from '../../store/cards'
import CommentsList from './commentsList'
import {CardDetailProvider} from './cardDetailContext'
import CardDetailContents from './cardDetailContents'
@ -27,6 +30,9 @@ import useImagePaste from './imagePaste'
import './cardDetail.scss'
export const OnboardingBoardTitle = 'Welcome to Boards!'
export const OnboardingCardTitle = 'Create a new card'
type Props = {
board: Board
activeView: BoardView
@ -79,6 +85,11 @@ const CardDetail = (props: Props): JSX.Element|null => {
mutator.changeIcon(card.id, card.fields.icon, newIcon)
}, [card.id, card.fields.icon])
const dispatch = useAppDispatch()
useEffect(() => {
dispatch(setCurrentCard(card.id))
}, [card.id])
if (!card) {
return null
}

View File

@ -60,6 +60,18 @@ describe('components/cardDetail/cardDetailContents', () => {
5: {username: 'g'},
},
},
boards: {
boards: {
[board.id]: board,
},
current: board.id,
},
cards: {
cards: {
[card.id]: card,
},
current: card.id,
},
}
const store = mockStateStore([], state)
const wrap = (child: ReactNode): ReactElement => (

View File

@ -12,6 +12,8 @@ import {useSortableWithGrip} from '../../hooks/sortable'
import ContentBlock from '../contentBlock'
import {MarkdownEditor} from '../markdownEditor'
import AddDescriptionTourStep from '../onboardingTour/addDescription/add_description'
import {dragAndDropRearrange} from './cardDetailContentsUtility'
export type Position = 'left' | 'right' | 'above' | 'below' | 'aboveRow' | 'belowRow'
@ -155,15 +157,17 @@ const CardDetailContents = (props: Props) => {
<div className='octo-content'>
{contents.map((block, x) =>
(
<ContentBlockWithDragAndDrop
key={x}
block={block}
x={x}
card={card}
contents={contents}
intl={intl}
readonly={props.readonly}
/>
<React.Fragment key={x}>
<ContentBlockWithDragAndDrop
block={block}
x={x}
card={card}
contents={contents}
intl={intl}
readonly={props.readonly}
/>
{x === 0 && <AddDescriptionTourStep/>}
</React.Fragment>
),
)}
</div>

View File

@ -8,6 +8,9 @@ import {mocked} from 'ts-jest/utils'
import '@testing-library/jest-dom'
import {createIntl} from 'react-intl'
import configureStore from 'redux-mock-store'
import {Provider as ReduxProvider} from 'react-redux'
import {PropertyType} from '../../blocks/board'
import {wrapIntl} from '../../testUtils'
import {TestBlockFactory} from '../../test/testBlockFactory'
@ -67,19 +70,53 @@ describe('components/cardDetail/CardDetailProperties', () => {
const cards = [card]
const state = {
users: {
me: {
id: 'user_id_1',
props: {
focalboard_onboardingTourStarted: true,
focalboard_tourCategory: 'card',
focalboard_onboardingTourStep: '1',
},
},
},
boards: {
boards: {
[board.id]: board,
},
current: board.id,
},
cards: {
cards: {
[card.id]: card,
},
current: card.id,
},
}
const mockStore = configureStore([])
let store = mockStore(state)
beforeEach(() => {
store = mockStore(state)
})
function renderComponent() {
const component = wrapIntl((
<CardDetailProperties
board={board!}
card={card}
cards={[card]}
contents={[]}
comments={[]}
activeView={view}
views={views}
readonly={false}
/>
))
const component = wrapIntl(
<ReduxProvider store={store}>
<CardDetailProperties
board={board!}
card={card}
cards={[card]}
contents={[]}
comments={[]}
activeView={view}
views={views}
readonly={false}
/>
</ReduxProvider>,
)
return render(component)
}

View File

@ -20,6 +20,7 @@ import ConfirmationDialogBox, {ConfirmationDialogBoxProps} from '../confirmation
import {sendFlashMessage} from '../flashMessages'
import Menu from '../../widgets/menu'
import {IDType, Utils} from '../../utils'
import AddPropertiesTourStep from '../onboardingTour/addProperties/add_properties'
type Props = {
board: Board
@ -197,6 +198,8 @@ const CardDetailProperties = (props: Props) => {
/>
</Menu>
</MenuWrapper>
<AddPropertiesTourStep/>
</div>
}
</div>

View File

@ -12,6 +12,7 @@
}
.CommentsList__new {
position: relative;
display: flex;
flex-direction: row;
align-items: center;

View File

@ -52,6 +52,18 @@ describe('components/cardDetail/CommentsList', () => {
{username: 'username_1'},
],
},
boards: {
boards: {
board_id_1: {title: 'Board'},
},
current: 'board_id_1',
},
cards: {
cards: {
card_id_1: {title: 'Card'},
},
current: 'card_id_1',
},
})
const component = (

View File

@ -14,7 +14,10 @@ import {MarkdownEditor} from '../markdownEditor'
import {IUser} from '../../user'
import {getMe} from '../../store/users'
import AddCommentTourStep from '../onboardingTour/addComments/addComments'
import Comment from './comment'
import './commentsList.scss'
type Props = {
@ -75,6 +78,8 @@ const CommentsList = (props: Props) => {
/>
</Button>
}
<AddCommentTourStep/>
</div>
)

View File

@ -56,6 +56,12 @@ describe('components/cardDialog', () => {
[card.id]: card,
},
},
boards: {
boards: {
[board.id]: board,
},
current: board.id,
},
users: {
workspaceUsers: {
1: {username: 'abc'},

View File

@ -52,7 +52,7 @@
justify-content: space-between;
}
}
> div:nth-child(2) {
padding: 0 0 0 1px;
@ -64,4 +64,9 @@
-webkit-overflow-scrolling: touch;
}
}
.shareButtonWrapper {
position: relative;
flex: 0 0 auto;
}
}

View File

@ -77,7 +77,12 @@ describe('components/centerPanel', () => {
},
searchText: '',
users: {
me: {},
me: {
id: 'user_id_1',
props: {
focalboard_onboardingTourStarted: false,
},
},
workspaceUsers: [
{username: 'username_1'},
],
@ -85,6 +90,9 @@ describe('components/centerPanel', () => {
},
boards: {
current: board.id,
boards: {
[board.id]: board,
},
},
cards: {
templates: [card1, card2],

View File

@ -22,10 +22,24 @@ import {updateView} from '../store/views'
import {getVisibleAndHiddenGroups} from '../boardUtils'
import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../../webapp/src/telemetry/telemetryClient'
import ShareBoardButton from './shareBoard/shareBoardButton'
import './centerPanel.scss'
import {RootState} from '../store'
import {
getMe,
getOnboardingTourCategory,
getOnboardingTourStarted,
getOnboardingTourStep,
patchProps,
} from '../store/users'
import {IUser, UserConfigPatch} from '../user'
import octoClient from '../octoClient'
import ShareBoardButton from './shareBoard/shareBoardButton'
import CardDialog from './cardDialog'
import RootPortal from './rootPortal'
import TopBar from './topBar'
@ -38,6 +52,8 @@ import Table from './table/table'
import CalendarFullView from './calendar/fullCalendar'
import Gallery from './gallery/gallery'
import {BoardTourSteps, FINISHED, TOUR_BOARD, TOUR_CARD} from './onboardingTour'
import ShareBoardTourStep from './onboardingTour/shareBoard/shareBoard'
type Props = {
clientConfig?: ClientConfig
@ -55,6 +71,12 @@ type Props = {
shownCardId?: string
showCard: (cardId?: string) => void
showShared: boolean
onboardingTourStarted: boolean
onboardingTourCategory: string
onboardingTourStep: string
me: IUser|null
patchProps: (props: Record<string, string>) => void
currentCard?: string
}
type State = {
@ -112,8 +134,45 @@ class CenterPanel extends React.Component<Props, State> {
return true
}
shouldStartBoardsTour(): boolean {
const isOnboardingBoard = this.props.board.title === 'Welcome to Boards!'
const isTourStarted = this.props.onboardingTourStarted
const completedCardsTour = this.props.onboardingTourCategory === TOUR_CARD && this.props.onboardingTourStep === FINISHED.toString()
const noCardOpen = !this.props.currentCard
return isOnboardingBoard && isTourStarted && completedCardsTour && noCardOpen
}
async prepareBoardsTour(): Promise<void> {
if (!this.props.me) {
return
}
const patch: UserConfigPatch = {
updatedFields: {
focalboard_tourCategory: TOUR_BOARD,
focalboard_onboardingTourStep: BoardTourSteps.ADD_VIEW.toString(),
},
}
const patchedProps = await octoClient.patchUserConfig(this.props.me.id, patch)
if (patchedProps) {
await this.props.patchProps(patchedProps)
}
}
async startBoardsTour(): Promise<void> {
if (!this.shouldStartBoardsTour()) {
return
}
await this.prepareBoardsTour()
}
componentDidUpdate(): void {
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ViewBoard, {board: this.props.board.id, view: this.props.activeView.id, viewType: this.props.activeView.fields.viewType})
this.startBoardsTour()
}
render(): JSX.Element {
@ -155,11 +214,16 @@ class CenterPanel extends React.Component<Props, State> {
board={board}
readonly={this.props.readonly}
/>
{!this.props.readonly && this.props.showShared &&
<ShareBoardButton
boardId={this.props.board.id}
/>
}
<div className='shareButtonWrapper'>
{!this.props.readonly && this.props.showShared &&
(
<ShareBoardButton
boardId={this.props.board.id}
/>
)
}
<ShareBoardTourStep/>
</div>
</div>
<ViewHeader
board={this.props.board}
@ -173,6 +237,7 @@ class CenterPanel extends React.Component<Props, State> {
addCardTemplate={this.addCardTemplate}
editCardTemplate={this.editCardTemplate}
readonly={this.props.readonly}
showShared={this.props.showShared}
/>
</div>
@ -412,4 +477,25 @@ class CenterPanel extends React.Component<Props, State> {
}
}
export default connect(undefined, {addCard, addTemplate, updateView})(injectIntl(CenterPanel))
function mapStateToProps(state: RootState) {
const onboardingTourStarted = getOnboardingTourStarted(state)
const onboardingTourCategory = getOnboardingTourCategory(state)
const onboardingTourStep = getOnboardingTourStep(state)
const me = getMe(state)
const currentCard = state.cards.current
return {
onboardingTourStarted,
onboardingTourCategory,
onboardingTourStep,
me,
currentCard,
}
}
export default connect(mapStateToProps, {
addCard,
addTemplate,
updateView,
patchProps,
})(injectIntl(CenterPanel))

View File

@ -246,6 +246,23 @@ exports[`components/sidebar/GlobalHeaderSettingsMenu imports menu open should ma
/>
</div>
</div>
<div
aria-label="Product tour"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Product tour
</div>
<div
class="noicon"
/>
</div>
</div>
<div
class="menu-spacer hideOnWidescreen"
@ -717,6 +734,23 @@ exports[`components/sidebar/GlobalHeaderSettingsMenu languages menu open should
/>
</div>
</div>
<div
aria-label="Product tour"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Product tour
</div>
<div
class="noicon"
/>
</div>
</div>
<div
class="menu-spacer hideOnWidescreen"
@ -879,6 +913,23 @@ exports[`components/sidebar/GlobalHeaderSettingsMenu settings menu open should m
/>
</div>
</div>
<div
aria-label="Product tour"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Product tour
</div>
<div
class="noicon"
/>
</div>
</div>
<div
class="menu-spacer hideOnWidescreen"

View File

@ -3,6 +3,7 @@
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {createMemoryHistory} from 'history'
import {render} from '@testing-library/react'
@ -14,6 +15,8 @@ import GlobalHeader from './globalHeader'
describe('components/sidebar/GlobalHeader', () => {
const mockStore = configureStore([])
const history = createMemoryHistory()
let store = mockStore({})
beforeEach(() => {
store = mockStore({})
@ -21,7 +24,7 @@ describe('components/sidebar/GlobalHeader', () => {
test('header menu should match snapshot', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<GlobalHeader/>
<GlobalHeader history={history}/>
</ReduxProvider>,
)

View File

@ -4,6 +4,7 @@
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {IntlProvider} from 'react-intl'
import {History} from 'history'
import HelpIcon from '../../widgets/icons/help'
import store from '../../store'
@ -17,7 +18,11 @@ import GlobalHeaderSettingsMenu from './globalHeaderSettingsMenu'
import './globalHeader.scss'
const HeaderItems = () => {
type HeaderItemProps = {
history: History<unknown>
}
const HeaderItems = (props: HeaderItemProps) => {
const language = useAppSelector<string>(getLanguage)
const helpUrl = 'https://www.focalboard.com/fwlink/doc-boards.html?v=' + Constants.versionString
@ -36,16 +41,20 @@ const HeaderItems = () => {
>
<HelpIcon/>
</a>
<GlobalHeaderSettingsMenu/>
<GlobalHeaderSettingsMenu history={props.history}/>
</div>
</IntlProvider>
)
}
const GlobalHeader = (): JSX.Element => {
type Props = {
history: History<unknown>
}
const GlobalHeader = (props: Props): JSX.Element => {
return (
<ReduxProvider store={store}>
<HeaderItems/>
<HeaderItems history={props.history}/>
</ReduxProvider>
)
}

View File

@ -3,6 +3,7 @@
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {createMemoryHistory} from 'history'
import {render} from '@testing-library/react'
@ -22,14 +23,21 @@ const mockedTelemetry = mocked(TelemetryClient, true)
describe('components/sidebar/GlobalHeaderSettingsMenu', () => {
const mockStore = configureStore([])
const history = createMemoryHistory()
let store = mockStore({})
beforeEach(() => {
store = mockStore({})
store = mockStore({
users: {
me: {
id: 'user-id',
},
},
})
})
test('settings menu closed should match snapshot', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<GlobalHeaderSettingsMenu/>
<GlobalHeaderSettingsMenu history={history}/>
</ReduxProvider>,
)
@ -40,7 +48,7 @@ describe('components/sidebar/GlobalHeaderSettingsMenu', () => {
test('settings menu open should match snapshot', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<GlobalHeaderSettingsMenu/>
<GlobalHeaderSettingsMenu history={history}/>
</ReduxProvider>,
)
@ -52,7 +60,7 @@ describe('components/sidebar/GlobalHeaderSettingsMenu', () => {
test('languages menu open should match snapshot', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<GlobalHeaderSettingsMenu/>
<GlobalHeaderSettingsMenu history={history}/>
</ReduxProvider>,
)
@ -66,7 +74,7 @@ describe('components/sidebar/GlobalHeaderSettingsMenu', () => {
window.open = jest.fn()
const component = wrapIntl(
<ReduxProvider store={store}>
<GlobalHeaderSettingsMenu/>
<GlobalHeaderSettingsMenu history={history}/>
</ReduxProvider>,
)

View File

@ -2,12 +2,16 @@
// See LICENSE.txt for license information.
import React, {useState} from 'react'
import {useIntl} from 'react-intl'
import {History} from 'history'
import {Archiver} from '../../archiver'
import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper'
import {useAppDispatch} from '../../store/hooks'
import {useAppDispatch, useAppSelector} from '../../store/hooks'
import {storeLanguage} from '../../store/language'
import {patchProps, getMe} from '../../store/users'
import {IUser, UserConfigPatch, UserPropPrefix} from '../../user'
import octoClient from '../../octoClient'
import {UserSettings} from '../../userSettings'
import CheckIcon from '../../widgets/icons/check'
import SettingsIcon from '../../widgets/icons/settings'
@ -17,8 +21,13 @@ import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../teleme
import './globalHeaderSettingsMenu.scss'
const GlobalHeaderSettingsMenu = () => {
type Props = {
history: History<unknown>
}
const GlobalHeaderSettingsMenu = (props: Props) => {
const intl = useIntl()
const me = useAppSelector<IUser|null>(getMe)
const dispatch = useAppDispatch()
const [randomIcons, setRandomIcons] = useState(UserSettings.prefillRandomIcons)
@ -92,6 +101,35 @@ const GlobalHeaderSettingsMenu = () => {
isOn={randomIcons}
onClick={async () => toggleRandomIcons()}
/>
<Menu.Text
id='product-tour'
name={intl.formatMessage({id: 'Sidebar.product-tour', defaultMessage: 'Product tour'})}
onClick={async () => {
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.StartTour)
if (!me) {
return
}
const patch: UserConfigPatch = {
updatedFields: {
[UserPropPrefix + 'onboardingTourStep']: '0',
[UserPropPrefix + 'tourCategory']: 'onboarding',
},
}
const patchedProps = await octoClient.patchUserConfig(me.id, patch)
if (patchedProps) {
await dispatch(patchProps(patchedProps))
}
const onboardingData = await octoClient.prepareOnboarding()
const newPath = `/workspace/${onboardingData?.workspaceID}/${onboardingData?.boardID}`
props.history.push(newPath)
}}
/>
</Menu>
</MenuWrapper>
</div>

View File

@ -144,13 +144,13 @@ exports[`src/component/kanban/kanban return kanban and change title on KanbanCol
class="octo-board-column"
>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -177,13 +177,13 @@ exports[`src/component/kanban/kanban return kanban and change title on KanbanCol
</div>
</div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -221,13 +221,13 @@ exports[`src/component/kanban/kanban return kanban and change title on KanbanCol
class="octo-board-column"
>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -589,13 +589,13 @@ exports[`src/component/kanban/kanban return kanban and click on KanbanCalculatio
class="octo-board-column"
>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -622,13 +622,13 @@ exports[`src/component/kanban/kanban return kanban and click on KanbanCalculatio
</div>
</div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -666,13 +666,13 @@ exports[`src/component/kanban/kanban return kanban and click on KanbanCalculatio
class="octo-board-column"
>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -875,13 +875,13 @@ exports[`src/component/kanban/kanban should match snapshot 1`] = `
class="octo-board-column"
>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -908,13 +908,13 @@ exports[`src/component/kanban/kanban should match snapshot 1`] = `
</div>
</div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -952,13 +952,13 @@ exports[`src/component/kanban/kanban should match snapshot 1`] = `
class="octo-board-column"
>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button

View File

@ -3,13 +3,13 @@
exports[`src/components/kanban/kanbanCard return kanbanCard and click on copy link menu 1`] = `
<div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -136,13 +136,13 @@ exports[`src/components/kanban/kanbanCard return kanbanCard and click on copy li
exports[`src/components/kanban/kanbanCard return kanbanCard and click on delete menu 1`] = `
<div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -269,13 +269,13 @@ exports[`src/components/kanban/kanbanCard return kanbanCard and click on delete
exports[`src/components/kanban/kanbanCard return kanbanCard and click on duplicate menu 1`] = `
<div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -402,13 +402,13 @@ exports[`src/components/kanban/kanbanCard return kanbanCard and click on duplica
exports[`src/components/kanban/kanbanCard should match snapshot 1`] = `
<div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="true"
style="opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper optionsMenu"
class="MenuWrapper optionsMenu "
role="button"
>
<button
@ -448,7 +448,7 @@ exports[`src/components/kanban/kanbanCard should match snapshot 1`] = `
exports[`src/components/kanban/kanbanCard should match snapshot with readonly 1`] = `
<div>
<div
class="KanbanCard"
class="KanbanCard false"
draggable="false"
style="opacity: 1;"
>

View File

@ -5,6 +5,7 @@ import {fireEvent, render, screen, waitFor} from '@testing-library/react'
import '@testing-library/jest-dom'
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {MemoryRouter} from 'react-router-dom'
import {mocked} from 'ts-jest/utils'
import userEvent from '@testing-library/user-event'
@ -66,6 +67,12 @@ describe('src/component/kanban/kanban', () => {
}
const state = {
users: {
me: {
id: 'user_id_1',
props: {},
},
},
cards: {
cards: [card1, card2, card3],
},
@ -110,7 +117,7 @@ describe('src/component/kanban/kanban', () => {
showCard={jest.fn()}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
expect(container).toMatchSnapshot()
})
test('do not return a kanban with groupByProperty undefined', () => {
@ -143,7 +150,7 @@ describe('src/component/kanban/kanban', () => {
showCard={jest.fn()}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
expect(mockedUtils.assertFailure).toBeCalled()
expect(container).toMatchSnapshot()
@ -178,7 +185,7 @@ describe('src/component/kanban/kanban', () => {
showCard={jest.fn()}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
const cardsElement = container.querySelectorAll('.KanbanCard')
expect(cardsElement).not.toBeNull()
@ -223,7 +230,7 @@ describe('src/component/kanban/kanban', () => {
showCard={jest.fn()}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
const cardsElement = container.querySelectorAll('.KanbanCard')
expect(cardsElement).not.toBeNull()
@ -268,7 +275,7 @@ describe('src/component/kanban/kanban', () => {
showCard={jest.fn()}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
const cardsElement = container.querySelectorAll('.KanbanCard')
expect(cardsElement).not.toBeNull()
@ -314,7 +321,7 @@ describe('src/component/kanban/kanban', () => {
showCard={jest.fn()}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
const allButtonsNew = screen.getAllByRole('button', {name: '+ New'})
expect(allButtonsNew).not.toBeNull()
userEvent.click(allButtonsNew[0])
@ -351,7 +358,7 @@ describe('src/component/kanban/kanban', () => {
showCard={jest.fn()}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
const buttonKanbanCalculation = screen.getByRole('button', {name: '2'})
expect(buttonKanbanCalculation).toBeDefined()
userEvent.click(buttonKanbanCalculation!)
@ -388,7 +395,7 @@ describe('src/component/kanban/kanban', () => {
showCard={jest.fn()}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
const inputTitle = screen.getByRole('textbox', {name: optionQ1.value})
expect(inputTitle).toBeDefined()
@ -432,7 +439,7 @@ describe('src/component/kanban/kanban', () => {
showCard={jest.fn()}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
const buttonAddGroup = screen.getByRole('button', {name: '+ Add a group'})
expect(buttonAddGroup).toBeDefined()
userEvent.click(buttonAddGroup)

View File

@ -33,6 +33,10 @@
display: none;
position: absolute;
right: 0;
&.show {
display: block;
}
}
.octo-tooltip {

View File

@ -3,6 +3,7 @@
import React from 'react'
import {render, screen, within} from '@testing-library/react'
import '@testing-library/jest-dom'
import {MemoryRouter} from 'react-router-dom'
import {Provider as ReduxProvider} from 'react-redux'
@ -53,6 +54,12 @@ describe('src/components/kanban/kanbanCard', () => {
comments: {
comments: {},
},
users: {
me: {
id: 'user_id_1',
props: {},
},
},
}
const store = mockStateStore([], state)
beforeEach(jest.clearAllMocks)
@ -71,7 +78,7 @@ describe('src/components/kanban/kanbanCard', () => {
isManualSort={false}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
expect(container).toMatchSnapshot()
})
test('should match snapshot with readonly', () => {
@ -89,7 +96,7 @@ describe('src/components/kanban/kanbanCard', () => {
isManualSort={false}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
expect(container).toMatchSnapshot()
})
test('return kanbanCard and click on delete menu ', () => {
@ -107,7 +114,7 @@ describe('src/components/kanban/kanbanCard', () => {
isManualSort={false}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
const {container} = result
@ -143,7 +150,7 @@ describe('src/components/kanban/kanbanCard', () => {
isManualSort={false}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
const elementMenuWrapper = screen.getByRole('button', {name: 'menuwrapper'})
expect(elementMenuWrapper).not.toBeNull()
userEvent.click(elementMenuWrapper)
@ -169,7 +176,7 @@ describe('src/components/kanban/kanbanCard', () => {
isManualSort={false}
/>
</ReduxProvider>,
))
), {wrapper: MemoryRouter})
const elementMenuWrapper = screen.getByRole('button', {name: 'menuwrapper'})
expect(elementMenuWrapper).not.toBeNull()
userEvent.click(elementMenuWrapper)

View File

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react'
import {useRouteMatch} from 'react-router-dom'
import {useIntl} from 'react-intl'
import {Board, IPropertyTemplate} from '../../blocks/board'
@ -26,6 +27,10 @@ import PropertyValueElement from '../propertyValueElement'
import ConfirmationDialogBox, {ConfirmationDialogBoxProps} from '../confirmationDialogBox'
import './kanbanCard.scss'
import CardBadges from '../cardBadges'
import OpenCardTourStep from '../onboardingTour/openCard/open_card'
import CopyLinkTourStep from '../onboardingTour/copyLink/copy_link'
export const OnboardingCardClassName = 'onboardingCard'
type Props = {
card: Card
@ -33,7 +38,7 @@ type Props = {
visiblePropertyTemplates: IPropertyTemplate[]
isSelected: boolean
visibleBadges: boolean
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
onClick?: (e: React.MouseEvent) => void
readonly: boolean
onDrop: (srcCard: Card, dstCard: Card) => void
showCard: (cardId?: string) => void
@ -45,6 +50,7 @@ const KanbanCard = (props: Props) => {
const intl = useIntl()
const [isDragging, isOver, cardRef] = useSortable('card', card, !props.readonly, props.onDrop)
const visiblePropertyTemplates = props.visiblePropertyTemplates || []
const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string}>()
let className = props.isSelected ? 'KanbanCard selected' : 'KanbanCard'
if (props.isManualSort && isOver) {
className += ' dragover'
@ -81,18 +87,27 @@ const KanbanCard = (props: Props) => {
setShowConfirmationDialogBox(true)
}
const isOnboardingCard = card.title === 'Create a new card'
const showOnboarding = isOnboardingCard && !match.params.cardId && !board.fields.isTemplate && Utils.isFocalboardPlugin()
const handleOnClick = async (e: React.MouseEvent) => {
if (props.onClick) {
props.onClick(e)
}
}
return (
<>
<div
ref={props.readonly ? () => null : cardRef}
className={className}
className={`${className} ${showOnboarding && OnboardingCardClassName}`}
draggable={!props.readonly}
style={{opacity: isDragging ? 0.5 : 1}}
onClick={props.onClick}
onClick={handleOnClick}
>
{!props.readonly &&
<MenuWrapper
className='optionsMenu'
className={`optionsMenu ${showOnboarding ? 'show' : ''}`}
stopPropagationOnToggle={true}
>
<IconButton icon={<OptionsIcon/>}/>
@ -168,6 +183,8 @@ const KanbanCard = (props: Props) => {
</Tooltip>
))}
{props.visibleBadges && <CardBadges card={card}/>}
{showOnboarding && !match.params.cardId && <OpenCardTourStep/>}
{showOnboarding && !match.params.cardId && <CopyLinkTourStep/>}
</div>
{showConfirmationDialogBox && <ConfirmationDialogBox dialogBox={confirmDialogProps}/>}

View File

@ -0,0 +1,139 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/onboardingTour/addComments/AddCommentTourStep after hover 1`] = `
<div
class="tippy-box tutorial-tour-tip__box AddCommentTourStep"
data-animation="scale-subtle"
data-escaped=""
data-placement="right-end"
data-reference-hidden=""
data-state="hidden"
role="tooltip"
style="max-width: 320px; transition-duration: 0ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="hidden"
style="transition-duration: 0ms;"
>
<div>
<div>
<div
class="tutorial-tour-tip__header"
>
<h4
class="tutorial-tour-tip__header__title"
>
Add comments
</h4>
<button
class="IconButton tutorial-tour-tip__header__close size--small"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="tutorial-tour-tip__body"
>
You can comment on issues, and even @mention your fellow Mattermost users to get their attention.
</div>
<div
class="tutorial-tour-tip__image"
>
<img
alt="tutorial tour tip product image"
src="test-file-stub"
/>
</div>
<div
class="tutorial-tour-tip__footer"
>
<div
class="tutorial-tour-tip__footer-buttons"
>
<div
class="tutorial-tour-tip__circles-ctr"
>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="0"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring tutorial-tour-tip__circular-ring-active"
>
<a
class="tutorial-tour-tip__circle active"
data-screen="1"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="2"
href="#"
/>
</div>
</div>
<div
class="tutorial-tour-tip__btn-ctr"
>
<button
class="Button emphasis--tertiary size--small"
title="Previous"
type="button"
>
<i
class="CompassIcon icon-chevron-left icon"
/>
<span>
Previous
</span>
</button>
<button
class="Button filled size--small tipNextButton"
type="button"
>
<span>
Next
</span>
<i
class="CompassIcon icon-chevron-right icon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="tippy-arrow"
style="position: absolute; top: 0px; transform: translate(0px, 3px);"
/>
</div>
`;
exports[`components/onboardingTour/addComments/AddCommentTourStep before hover 1`] = `
<div>
<div
aria-expanded="true"
class="tutorial-tour-tip__pulsating-dot-ctr AddCommentTourStep"
>
<span
class="pulsating_dot"
/>
</div>
</div>
`;

View File

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import {Provider as ReduxProvider} from 'react-redux'
import {wrapIntl} from '../../../testUtils'
import AddCommentTourStep from './addComments'
describe('components/onboardingTour/addComments/AddCommentTourStep', () => {
const mockStore = configureStore([])
const state = {
users: {
me: {
id: 'user_id_1',
props: {
focalboard_onboardingTourStarted: true,
focalboard_tourCategory: 'card',
focalboard_onboardingTourStep: '1',
},
},
},
boards: {
boards: {
board_id_1: {title: 'Welcome to Boards!'},
},
current: 'board_id_1',
},
cards: {
cards: {
card_id_1: {title: 'Create a new card'},
},
current: 'card_id_1',
},
}
let store = mockStore(state)
beforeEach(() => {
store = mockStore(state)
})
test('before hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<AddCommentTourStep/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('after hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<AddCommentTourStep/>
</ReduxProvider>,
)
render(component)
const elements = document.querySelectorAll('.AddCommentTourStep')
expect(elements.length).toBe(2)
expect(elements[1]).toMatchSnapshot()
})
})

View File

@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {useMeasurePunchouts} from '../../tutorial_tour_tip/hooks'
import './add_comments.scss'
import {Utils} from '../../../utils'
import addComment from '../../../../static/comment.gif'
import {CardTourSteps, TOUR_CARD} from '../index'
import TourTipRenderer from '../tourTipRenderer/tourTipRenderer'
const AddCommentTourStep = (): JSX.Element | null => {
const title = (
<FormattedMessage
id='OnboardingTour.AddComments.Title'
defaultMessage='Add comments'
/>
)
const screen = (
<FormattedMessage
id='OnboardingTour.AddComments.Body'
defaultMessage='You can comment on issues, and even @mention your fellow Mattermost users to get their attention.'
/>
)
const punchout = useMeasurePunchouts(['.CommentsList__new'], [])
return (
<TourTipRenderer
key='AddCommentTourStep'
requireCard={true}
category={TOUR_CARD}
step={CardTourSteps.ADD_COMMENTS}
screen={screen}
title={title}
punchout={punchout}
classname='AddCommentTourStep'
telemetryTag='tourPoint2b'
placement={'right-end'}
imageURL={Utils.buildURL(addComment, true)}
hideBackdrop={true}
/>
)
}
export default AddCommentTourStep

View File

@ -0,0 +1,14 @@
.AddCommentTourStep {
&.tutorial-tour-tip__pulsating-dot-ctr {
left: 170px;
top: 18px;
}
.tippy-arrow {
top: -21px !important;
}
&.tippy-box.tutorial-tour-tip__box {
top: 24px;
}
}

View File

@ -0,0 +1,137 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/onboardingTour/addComments/AddDescriptionTourStep after hover 1`] = `
<div
class="tippy-box tutorial-tour-tip__box AddDescriptionTourStep"
data-animation="scale-subtle"
data-escaped=""
data-placement="top-start"
data-reference-hidden=""
data-state="hidden"
role="tooltip"
style="max-width: 320px; transition-duration: 0ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="hidden"
style="transition-duration: 0ms;"
>
<div>
<div>
<div
class="tutorial-tour-tip__header"
>
<h4
class="tutorial-tour-tip__header__title"
>
Add description
</h4>
<button
class="IconButton tutorial-tour-tip__header__close size--small"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="tutorial-tour-tip__body"
>
Add a description to your card so your teammates know what the card is about.
</div>
<div
class="tutorial-tour-tip__image"
>
<img
alt="tutorial tour tip product image"
src="test-file-stub"
/>
</div>
<div
class="tutorial-tour-tip__footer"
>
<div
class="tutorial-tour-tip__footer-buttons"
>
<div
class="tutorial-tour-tip__circles-ctr"
>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="0"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="1"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring tutorial-tour-tip__circular-ring-active"
>
<a
class="tutorial-tour-tip__circle active"
data-screen="2"
href="#"
/>
</div>
</div>
<div
class="tutorial-tour-tip__btn-ctr"
>
<button
class="Button emphasis--tertiary size--small"
title="Previous"
type="button"
>
<i
class="CompassIcon icon-chevron-left icon"
/>
<span>
Previous
</span>
</button>
<button
class="Button filled size--small tipNextButton"
type="button"
>
<span>
Done
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="tippy-arrow"
style="position: absolute; left: 0px; transform: translate(3px, 0px);"
/>
</div>
`;
exports[`components/onboardingTour/addComments/AddDescriptionTourStep before hover 1`] = `
<div>
<div
aria-expanded="true"
class="tutorial-tour-tip__pulsating-dot-ctr AddDescriptionTourStep"
>
<span
class="pulsating_dot"
/>
</div>
</div>
`;

View File

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import {Provider as ReduxProvider} from 'react-redux'
import {wrapIntl} from '../../../testUtils'
import AddDescriptionTourStep from './add_description'
describe('components/onboardingTour/addComments/AddDescriptionTourStep', () => {
const mockStore = configureStore([])
const state = {
users: {
me: {
id: 'user_id_1',
props: {
focalboard_onboardingTourStarted: true,
focalboard_tourCategory: 'card',
focalboard_onboardingTourStep: '2',
},
},
},
boards: {
boards: {
board_id_1: {title: 'Welcome to Boards!'},
},
current: 'board_id_1',
},
cards: {
cards: {
card_id_1: {title: 'Create a new card'},
},
current: 'card_id_1',
},
}
let store = mockStore(state)
beforeEach(() => {
store = mockStore(state)
})
test('before hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<AddDescriptionTourStep/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('after hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<AddDescriptionTourStep/>
</ReduxProvider>,
)
render(component)
const elements = document.querySelectorAll('.AddDescriptionTourStep')
expect(elements.length).toBe(2)
expect(elements[1]).toMatchSnapshot()
})
})

View File

@ -0,0 +1,14 @@
.AddDescriptionTourStep {
&.tutorial-tour-tip__pulsating-dot-ctr {
top: -10px;
left: calc(120px + 20%);
}
.tippy-arrow {
left: 21px !important;
}
&.tippy-box.tutorial-tour-tip__box {
left: -21px;
}
}

View File

@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {useMeasurePunchouts} from '../../tutorial_tour_tip/hooks'
import './add_description.scss'
import {Utils} from '../../../utils'
import addDescription from '../../../../static/addDescription.png'
import {CardTourSteps, TOUR_CARD} from '../index'
import TourTipRenderer from '../tourTipRenderer/tourTipRenderer'
const AddDescriptionTourStep = (): JSX.Element | null => {
const title = (
<FormattedMessage
id='OnboardingTour.AddDescription.Title'
defaultMessage='Add description'
/>
)
const screen = (
<FormattedMessage
id='OnboardingTour.AddDescription.Body'
defaultMessage='Add a description to your card so your teammates know what the card is about.'
/>
)
const punchout = useMeasurePunchouts(['.octo-content div:nth-child(1)'], [])
return (
<TourTipRenderer
key='AddDescriptionTourStep'
requireCard={true}
category={TOUR_CARD}
step={CardTourSteps.ADD_DESCRIPTION}
screen={screen}
title={title}
punchout={punchout}
classname='AddDescriptionTourStep'
telemetryTag='tourPoint2c'
placement={'top-start'}
imageURL={Utils.buildURL(addDescription, true)}
hideBackdrop={true}
/>
)
}
export default AddDescriptionTourStep

View File

@ -0,0 +1,127 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/onboardingTour/addComments/AddPropertiesTourStep after hover 1`] = `
<div
class="tippy-box tutorial-tour-tip__box AddPropertiesTourStep"
data-animation="scale-subtle"
data-escaped=""
data-placement="right-end"
data-reference-hidden=""
data-state="hidden"
role="tooltip"
style="max-width: 320px; transition-duration: 0ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="hidden"
style="transition-duration: 0ms;"
>
<div>
<div>
<div
class="tutorial-tour-tip__header"
>
<h4
class="tutorial-tour-tip__header__title"
>
Add properties
</h4>
<button
class="IconButton tutorial-tour-tip__header__close size--small"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="tutorial-tour-tip__body"
>
Add various properties to cards to make them more powerful!
</div>
<div
class="tutorial-tour-tip__image"
>
<img
alt="tutorial tour tip product image"
src="test-file-stub"
/>
</div>
<div
class="tutorial-tour-tip__footer"
>
<div
class="tutorial-tour-tip__footer-buttons"
>
<div
class="tutorial-tour-tip__circles-ctr"
>
<div
class="tutorial-tour-tip__circular-ring tutorial-tour-tip__circular-ring-active"
>
<a
class="tutorial-tour-tip__circle active"
data-screen="0"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="1"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="2"
href="#"
/>
</div>
</div>
<div
class="tutorial-tour-tip__btn-ctr"
>
<button
class="Button filled size--small tipNextButton"
type="button"
>
<span>
Next
</span>
<i
class="CompassIcon icon-chevron-right icon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="tippy-arrow"
style="position: absolute; top: 0px; transform: translate(0px, 3px);"
/>
</div>
`;
exports[`components/onboardingTour/addComments/AddPropertiesTourStep before hover 1`] = `
<div>
<div
aria-expanded="true"
class="tutorial-tour-tip__pulsating-dot-ctr AddPropertiesTourStep"
>
<span
class="pulsating_dot"
/>
</div>
</div>
`;

View File

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import {Provider as ReduxProvider} from 'react-redux'
import {wrapIntl} from '../../../testUtils'
import AddPropertiesTourStep from './add_properties'
describe('components/onboardingTour/addComments/AddPropertiesTourStep', () => {
const mockStore = configureStore([])
const state = {
users: {
me: {
id: 'user_id_1',
props: {
focalboard_onboardingTourStarted: true,
focalboard_tourCategory: 'card',
focalboard_onboardingTourStep: '0',
},
},
},
boards: {
boards: {
board_id_1: {title: 'Welcome to Boards!'},
},
current: 'board_id_1',
},
cards: {
cards: {
card_id_1: {title: 'Create a new card'},
},
current: 'card_id_1',
},
}
let store = mockStore(state)
beforeEach(() => {
store = mockStore(state)
})
test('before hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<AddPropertiesTourStep/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('after hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<AddPropertiesTourStep/>
</ReduxProvider>,
)
render(component)
const elements = document.querySelectorAll('.AddPropertiesTourStep')
expect(elements.length).toBe(2)
expect(elements[1]).toMatchSnapshot()
})
})

View File

@ -0,0 +1,14 @@
.AddPropertiesTourStep {
&.tutorial-tour-tip__pulsating-dot-ctr {
left: 100%;
top: calc(50% - 6px);
}
.tippy-arrow {
top: -21px !important;
}
&.tippy-box.tutorial-tour-tip__box {
top: 24px;
}
}

View File

@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {useMeasurePunchouts} from '../../tutorial_tour_tip/hooks'
import './add_properties.scss'
import {Utils} from '../../../utils'
import addProperty from '../../../../static/addProperty.gif'
import {CardTourSteps, TOUR_CARD} from '../index'
import TourTipRenderer from '../tourTipRenderer/tourTipRenderer'
const AddPropertiesTourStep = (): JSX.Element | null => {
const title = (
<FormattedMessage
id='OnboardingTour.AddProperties.Title'
defaultMessage='Add properties'
/>
)
const screen = (
<FormattedMessage
id='OnboardingTour.AddProperties.Body'
defaultMessage='Add various properties to cards to make them more powerful!'
/>
)
const punchout = useMeasurePunchouts(['.octo-propertyname.add-property'], [])
return (
<TourTipRenderer
key='AddPropertiesTourStep'
requireCard={true}
category={TOUR_CARD}
step={CardTourSteps.ADD_PROPERTIES}
screen={screen}
title={title}
punchout={punchout}
classname='AddPropertiesTourStep'
telemetryTag='tourPoint2a'
placement={'right-end'}
imageURL={Utils.buildURL(addProperty, true)}
hideBackdrop={true}
/>
)
}
export default AddPropertiesTourStep

View File

@ -0,0 +1,127 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/onboardingTour/addComments/AddViewTourStep after hover 1`] = `
<div
class="tippy-box tutorial-tour-tip__box AddViewTourStep"
data-animation="scale-subtle"
data-escaped=""
data-placement="bottom-start"
data-reference-hidden=""
data-state="hidden"
role="tooltip"
style="max-width: 320px; transition-duration: 0ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="hidden"
style="transition-duration: 0ms;"
>
<div>
<div>
<div
class="tutorial-tour-tip__header"
>
<h4
class="tutorial-tour-tip__header__title"
>
Add a new view
</h4>
<button
class="IconButton tutorial-tour-tip__header__close size--small"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="tutorial-tour-tip__body"
>
Go here to create a new view to organise your board using different layouts.
</div>
<div
class="tutorial-tour-tip__image"
>
<img
alt="tutorial tour tip product image"
src="test-file-stub"
/>
</div>
<div
class="tutorial-tour-tip__footer"
>
<div
class="tutorial-tour-tip__footer-buttons"
>
<div
class="tutorial-tour-tip__circles-ctr"
>
<div
class="tutorial-tour-tip__circular-ring tutorial-tour-tip__circular-ring-active"
>
<a
class="tutorial-tour-tip__circle active"
data-screen="0"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="1"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="2"
href="#"
/>
</div>
</div>
<div
class="tutorial-tour-tip__btn-ctr"
>
<button
class="Button filled size--small tipNextButton"
type="button"
>
<span>
Next
</span>
<i
class="CompassIcon icon-chevron-right icon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="tippy-arrow"
style="position: absolute; left: 0px; transform: translate(3px, 0px);"
/>
</div>
`;
exports[`components/onboardingTour/addComments/AddViewTourStep before hover 1`] = `
<div>
<div
aria-expanded="true"
class="tutorial-tour-tip__pulsating-dot-ctr AddViewTourStep"
>
<span
class="pulsating_dot"
/>
</div>
</div>
`;

View File

@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import {Provider as ReduxProvider} from 'react-redux'
import {wrapIntl} from '../../../testUtils'
import AddViewTourStep from './add_view'
describe('components/onboardingTour/addComments/AddViewTourStep', () => {
const mockStore = configureStore([])
const state = {
users: {
me: {
id: 'user_id_1',
props: {
focalboard_onboardingTourStarted: true,
focalboard_tourCategory: 'board',
focalboard_onboardingTourStep: '0',
},
},
},
boards: {
boards: {
board_id_1: {title: 'Welcome to Boards!'},
},
current: 'board_id_1',
},
}
let store = mockStore(state)
beforeEach(() => {
store = mockStore(state)
})
test('before hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<AddViewTourStep/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('after hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<AddViewTourStep/>
</ReduxProvider>,
)
render(component)
const elements = document.querySelectorAll('.AddViewTourStep')
expect(elements.length).toBe(2)
expect(elements[1]).toMatchSnapshot()
})
})

View File

@ -0,0 +1,14 @@
.AddViewTourStep {
&.tutorial-tour-tip__pulsating-dot-ctr {
left: 91%;
top: 100%;
}
.tippy-arrow {
left: 21px !important;
}
&.tippy-box.tutorial-tour-tip__box {
right: 22px;
}
}

View File

@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {useMeasurePunchouts} from '../../tutorial_tour_tip/hooks'
import './add_view.scss'
import {Utils} from '../../../utils'
import changeViews from '../../../../static/changeViews.gif'
import {BoardTourSteps, TOUR_BOARD} from '../index'
import TourTipRenderer from '../tourTipRenderer/tourTipRenderer'
const AddViewTourStep = (): JSX.Element => {
const title = (
<FormattedMessage
id='OnboardingTour.AddView.Title'
defaultMessage='Add a new view'
/>
)
const screen = (
<FormattedMessage
id='OnboardingTour.AddView.Body'
defaultMessage='Go here to create a new view to organise your board using different layouts.'
/>
)
const punchout = useMeasurePunchouts(['.viewSelector'], [])
return (
<TourTipRenderer
key='AddViewTourStep'
requireCard={false}
category={TOUR_BOARD}
step={BoardTourSteps.ADD_VIEW}
screen={screen}
title={title}
punchout={punchout}
classname='AddViewTourStep'
telemetryTag='tourPoint3a'
placement={'bottom-start'}
imageURL={Utils.buildURL(changeViews, true)}
hideBackdrop={false}
/>
)
}
export default AddViewTourStep

View File

@ -0,0 +1,139 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/onboardingTour/addComments/CopyLinkTourStep after hover 1`] = `
<div
class="tippy-box tutorial-tour-tip__box CopyLinkTourStep"
data-animation="scale-subtle"
data-escaped=""
data-placement="right-start"
data-reference-hidden=""
data-state="hidden"
role="tooltip"
style="max-width: 320px; transition-duration: 0ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="hidden"
style="transition-duration: 0ms;"
>
<div>
<div>
<div
class="tutorial-tour-tip__header"
>
<h4
class="tutorial-tour-tip__header__title"
>
Copy link
</h4>
<button
class="IconButton tutorial-tour-tip__header__close size--small"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="tutorial-tour-tip__body"
>
You can share your cards with teammates by copying the link and pasting it in a channel, Direct Message, or Group Message.
</div>
<div
class="tutorial-tour-tip__image"
>
<img
alt="tutorial tour tip product image"
src="test-file-stub"
/>
</div>
<div
class="tutorial-tour-tip__footer"
>
<div
class="tutorial-tour-tip__footer-buttons"
>
<div
class="tutorial-tour-tip__circles-ctr"
>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="0"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring tutorial-tour-tip__circular-ring-active"
>
<a
class="tutorial-tour-tip__circle active"
data-screen="1"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="2"
href="#"
/>
</div>
</div>
<div
class="tutorial-tour-tip__btn-ctr"
>
<button
class="Button emphasis--tertiary size--small"
title="Previous"
type="button"
>
<i
class="CompassIcon icon-chevron-left icon"
/>
<span>
Previous
</span>
</button>
<button
class="Button filled size--small tipNextButton"
type="button"
>
<span>
Next
</span>
<i
class="CompassIcon icon-chevron-right icon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="tippy-arrow"
style="position: absolute; top: 0px; transform: translate(0px, 3px);"
/>
</div>
`;
exports[`components/onboardingTour/addComments/CopyLinkTourStep before hover 1`] = `
<div>
<div
aria-expanded="true"
class="tutorial-tour-tip__pulsating-dot-ctr CopyLinkTourStep"
>
<span
class="pulsating_dot"
/>
</div>
</div>
`;

View File

@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import {Provider as ReduxProvider} from 'react-redux'
import {wrapIntl} from '../../../testUtils'
import CopyLinkTourStep from './copy_link'
describe('components/onboardingTour/addComments/CopyLinkTourStep', () => {
const mockStore = configureStore([])
const state = {
users: {
me: {
id: 'user_id_1',
props: {
focalboard_onboardingTourStarted: true,
focalboard_tourCategory: 'board',
focalboard_onboardingTourStep: '1',
},
},
},
boards: {
boards: {
board_id_1: {title: 'Welcome to Boards!'},
},
current: 'board_id_1',
},
}
let store = mockStore(state)
beforeEach(() => {
store = mockStore(state)
})
test('before hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<CopyLinkTourStep/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('after hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<CopyLinkTourStep/>
</ReduxProvider>,
)
render(component)
const elements = document.querySelectorAll('.CopyLinkTourStep')
expect(elements.length).toBe(2)
expect(elements[1]).toMatchSnapshot()
})
})

View File

@ -0,0 +1,14 @@
.CopyLinkTourStep {
&.tutorial-tour-tip__pulsating-dot-ctr {
left: calc(100% - 6px);
top: 18px;
}
.tippy-arrow {
top: 21px !important;
}
&.tippy-box.tutorial-tour-tip__box {
top: -24px;
}
}

View File

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {useMeasurePunchouts} from '../../tutorial_tour_tip/hooks'
import './copy_link.scss'
import {Utils} from '../../../utils'
import copyLink from '../../../../static/copyLink.gif'
import {BoardTourSteps, TOUR_BOARD} from '../index'
import {OnboardingCardClassName} from '../../kanban/kanbanCard'
import TourTipRenderer from '../tourTipRenderer/tourTipRenderer'
const CopyLinkTourStep = (): JSX.Element | null => {
const title = (
<FormattedMessage
id='OnboardingTour.CopyLink.Title'
defaultMessage='Copy link'
/>
)
const screen = (
<FormattedMessage
id='OnboardingTour.CopyLink.Body'
defaultMessage='You can share your cards with teammates by copying the link and pasting it in a channel, Direct Message, or Group Message.'
/>
)
const punchout = useMeasurePunchouts([`.${OnboardingCardClassName} .optionsMenu`], [])
return (
<TourTipRenderer
key='CopyLinkTourStep'
requireCard={false}
category={TOUR_BOARD}
step={BoardTourSteps.COPY_LINK}
screen={screen}
title={title}
punchout={punchout}
classname='CopyLinkTourStep'
telemetryTag='tourPoint3b'
placement={'right-start'}
imageURL={Utils.buildURL(copyLink, true)}
hideBackdrop={true}
/>
)
}
export default CopyLinkTourStep

View File

@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const BaseTourSteps = {
OPEN_A_CARD: 0,
}
export const CardTourSteps = {
ADD_PROPERTIES: 0,
ADD_COMMENTS: 1,
ADD_DESCRIPTION: 2,
}
export const BoardTourSteps: {[key: string]: number} = {
ADD_VIEW: 0,
COPY_LINK: 1,
SHARE_BOARD: 2,
}
export const FINISHED = 999
export const TOUR_BASE = 'onboarding'
export const TOUR_CARD = 'card'
export const TOUR_BOARD = 'board'
export const TOUR_ORDER = [
TOUR_BASE,
TOUR_CARD,
TOUR_BOARD,
]
export const TourCategoriesMapToSteps: Record<string, Record<string, number>> = {
[TOUR_BASE]: BaseTourSteps,
[TOUR_CARD]: CardTourSteps,
[TOUR_BOARD]: BoardTourSteps,
}

View File

@ -0,0 +1,79 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/onboardingTour/addComments/OpenCardTourStep after hover 1`] = `
<div
class="tippy-box tutorial-tour-tip__box OpenCardTourStep"
data-animation="scale-subtle"
data-escaped=""
data-placement="top"
data-reference-hidden=""
data-state="hidden"
role="tooltip"
style="max-width: 320px; transition-duration: 0ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="hidden"
style="transition-duration: 0ms;"
>
<div>
<div>
<div
class="tutorial-tour-tip__header"
>
<h4
class="tutorial-tour-tip__header__title"
>
Open a card
</h4>
<button
class="IconButton tutorial-tour-tip__header__close size--small"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="tutorial-tour-tip__body"
>
Open a card to explore the powerful ways that Boards can help you organize your work.
</div>
<div
class="tutorial-tour-tip__footer"
>
<div
class="tutorial-tour-tip__footer-buttons"
>
<div
class="tutorial-tour-tip__circles-ctr"
/>
<div
class="tutorial-tour-tip__btn-ctr"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="tippy-arrow"
style="position: absolute; left: 0px; transform: translate(3px, 0px);"
/>
</div>
`;
exports[`components/onboardingTour/addComments/OpenCardTourStep before hover 1`] = `
<div>
<div
aria-expanded="true"
class="tutorial-tour-tip__pulsating-dot-ctr OpenCardTourStep"
>
<span
class="pulsating_dot"
/>
</div>
</div>
`;

View File

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import {Provider as ReduxProvider} from 'react-redux'
import {wrapIntl} from '../../../testUtils'
import OpenCardTourStep from './open_card'
describe('components/onboardingTour/addComments/OpenCardTourStep', () => {
const mockStore = configureStore([])
const state = {
users: {
me: {
id: 'user_id_1',
props: {
focalboard_onboardingTourStarted: true,
focalboard_tourCategory: 'onboarding',
focalboard_onboardingTourStep: '0',
},
},
},
boards: {
boards: {
board_id_1: {title: 'Welcome to Boards!'},
},
current: 'board_id_1',
},
cards: {
cards: {
card_id_1: {title: 'Create a new card'},
},
current: 'card_id_1',
},
}
let store = mockStore(state)
beforeEach(() => {
store = mockStore(state)
})
test('before hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<OpenCardTourStep/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('after hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<OpenCardTourStep/>
</ReduxProvider>,
)
render(component)
const elements = document.querySelectorAll('.OpenCardTourStep')
expect(elements.length).toBe(2)
expect(elements[1]).toMatchSnapshot()
})
})

View File

@ -0,0 +1,10 @@
.OpenCardTourStep {
&.tutorial-tour-tip__pulsating-dot-ctr {
left: 80%;
top: calc(100% - 6px);
}
.tutorial-tour-tip__overlay {
cursor: pointer;
}
}

View File

@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {bottom} from '@popperjs/core'
import {FormattedMessage} from 'react-intl'
import {useMeasurePunchouts} from '../../tutorial_tour_tip/hooks'
import {BaseTourSteps, TOUR_BASE} from '../index'
import './open_card.scss'
import {OnboardingCardClassName} from '../../kanban/kanbanCard'
import TourTipRenderer from '../tourTipRenderer/tourTipRenderer'
const OpenCardTourStep = (): JSX.Element | null => {
const title = (
<FormattedMessage
id='OnboardingTour.OpenACard.Title'
defaultMessage='Open a card'
/>
)
const screen = (
<FormattedMessage
id='OnboardingTour.OpenACard.Body'
defaultMessage='Open a card to explore the powerful ways that Boards can help you organize your work.'
/>
)
const punchout = useMeasurePunchouts([`.${OnboardingCardClassName}`], [])
return (
<TourTipRenderer
key='OpenCardTourStep'
requireCard={false}
category={TOUR_BASE}
step={BaseTourSteps.OPEN_A_CARD}
screen={screen}
title={title}
punchout={punchout}
classname='OpenCardTourStep'
telemetryTag='tourPoint1'
placement={bottom}
singleTip={true}
hideNavButtons={true}
hideBackdrop={false}
/>
)
}
export default OpenCardTourStep

View File

@ -0,0 +1,137 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/onboardingTour/addComments/ShareBoardTourStep after hover 1`] = `
<div
class="tippy-box tutorial-tour-tip__box ShareBoardTourStep"
data-animation="scale-subtle"
data-escaped=""
data-placement="bottom-end"
data-reference-hidden=""
data-state="hidden"
role="tooltip"
style="max-width: 320px; transition-duration: 0ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="hidden"
style="transition-duration: 0ms;"
>
<div>
<div>
<div
class="tutorial-tour-tip__header"
>
<h4
class="tutorial-tour-tip__header__title"
>
Share board
</h4>
<button
class="IconButton tutorial-tour-tip__header__close size--small"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
</div>
<div
class="tutorial-tour-tip__body"
>
You can share your board internally, within your team, or publish it publicly for visibility outside of your organization.
</div>
<div
class="tutorial-tour-tip__image"
>
<img
alt="tutorial tour tip product image"
src="test-file-stub"
/>
</div>
<div
class="tutorial-tour-tip__footer"
>
<div
class="tutorial-tour-tip__footer-buttons"
>
<div
class="tutorial-tour-tip__circles-ctr"
>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="0"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring"
>
<a
class="tutorial-tour-tip__circle"
data-screen="1"
href="#"
/>
</div>
<div
class="tutorial-tour-tip__circular-ring tutorial-tour-tip__circular-ring-active"
>
<a
class="tutorial-tour-tip__circle active"
data-screen="2"
href="#"
/>
</div>
</div>
<div
class="tutorial-tour-tip__btn-ctr"
>
<button
class="Button emphasis--tertiary size--small"
title="Previous"
type="button"
>
<i
class="CompassIcon icon-chevron-left icon"
/>
<span>
Previous
</span>
</button>
<button
class="Button filled size--small tipNextButton"
type="button"
>
<span>
Done
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="tippy-arrow"
style="position: absolute; left: 0px; transform: translate(3px, 0px);"
/>
</div>
`;
exports[`components/onboardingTour/addComments/ShareBoardTourStep before hover 1`] = `
<div>
<div
aria-expanded="true"
class="tutorial-tour-tip__pulsating-dot-ctr ShareBoardTourStep"
>
<span
class="pulsating_dot"
/>
</div>
</div>
`;

View File

@ -0,0 +1,14 @@
.ShareBoardTourStep {
&.tutorial-tour-tip__pulsating-dot-ctr {
left: calc(80% - 16px);
top: 80px;
}
.tippy-arrow {
left: -20px !important;
}
&.tippy-box.tutorial-tour-tip__box {
right: -22px;
}
}

View File

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import {Provider as ReduxProvider} from 'react-redux'
import {wrapIntl} from '../../../testUtils'
import ShareBoardTourStep from './shareBoard'
describe('components/onboardingTour/addComments/ShareBoardTourStep', () => {
const mockStore = configureStore([])
const state = {
users: {
me: {
id: 'user_id_1',
props: {
focalboard_onboardingTourStarted: true,
focalboard_tourCategory: 'board',
focalboard_onboardingTourStep: '2',
},
},
},
boards: {
boards: {
board_id_1: {title: 'Welcome to Boards!'},
},
current: 'board_id_1',
},
cards: {
cards: {
card_id_1: {title: 'Create a new card'},
},
current: 'card_id_1',
},
}
let store = mockStore(state)
beforeEach(() => {
store = mockStore(state)
})
test('before hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<ShareBoardTourStep/>
</ReduxProvider>,
)
const {container} = render(component)
expect(container).toMatchSnapshot()
})
test('after hover', () => {
const component = wrapIntl(
<ReduxProvider store={store}>
<ShareBoardTourStep/>
</ReduxProvider>,
)
render(component)
const elements = document.querySelectorAll('.ShareBoardTourStep')
expect(elements.length).toBe(2)
expect(elements[1]).toMatchSnapshot()
})
})

View File

@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {useMeasurePunchouts} from '../../tutorial_tour_tip/hooks'
import './shareBoard.scss'
import {Utils} from '../../../utils'
import shareBoard from '../../../../static/share.gif'
import {BoardTourSteps, TOUR_BOARD} from '../index'
import TourTipRenderer from '../tourTipRenderer/tourTipRenderer'
const ShareBoardTourStep = (): JSX.Element | null => {
const title = (
<FormattedMessage
id='OnboardingTour.ShareBoard.Title'
defaultMessage='Share board'
/>
)
const screen = (
<FormattedMessage
id='OnboardingTour.ShareBoard.Body'
defaultMessage='You can share your board internally, within your team, or publish it publicly for visibility outside of your organization.'
/>
)
const punchout = useMeasurePunchouts(['.ShareBoardButton > button'], [])
if (!BoardTourSteps.SHARE_BOARD) {
return null
}
return (
<TourTipRenderer
key='ShareBoardTourStep'
requireCard={false}
category={TOUR_BOARD}
step={BoardTourSteps.SHARE_BOARD}
screen={screen}
title={title}
punchout={punchout}
classname='ShareBoardTourStep'
telemetryTag='tourPoint2b'
placement={'bottom-end'}
imageURL={Utils.buildURL(shareBoard, true)}
hideBackdrop={true}
/>
)
}
export default ShareBoardTourStep

View File

@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {Placement} from 'tippy.js'
import {useAppSelector} from '../../../store/hooks'
import {getCurrentBoard} from '../../../store/boards'
import {getCurrentCard} from '../../../store/cards'
import {OnboardingBoardTitle, OnboardingCardTitle} from '../../cardDetail/cardDetail'
import {getOnboardingTourCategory, getOnboardingTourStarted, getOnboardingTourStep} from '../../../store/users'
import TourTip from '../../tutorial_tour_tip/tutorial_tour_tip'
import {TutorialTourTipPunchout} from '../../tutorial_tour_tip/tutorial_tour_tip_backdrop'
type Props = {
requireCard: boolean
category: string
step: number
screen: JSX.Element
title: JSX.Element
punchout: TutorialTourTipPunchout | null | undefined
classname: string
telemetryTag: string
placement: Placement | undefined
hideBackdrop: boolean
imageURL?: string
singleTip?: boolean
hideNavButtons?: boolean
}
const TourTipRenderer = (props: Props): JSX.Element | null => {
const board = useAppSelector(getCurrentBoard)
const isOnboardingBoard = board ? board.title === OnboardingBoardTitle : false
const onboardingTourStarted = useAppSelector(getOnboardingTourStarted)
const onboardingTourCategory = useAppSelector(getOnboardingTourCategory)
const onboardingTourStep = useAppSelector(getOnboardingTourStep)
const showTour = isOnboardingBoard && onboardingTourStarted && onboardingTourCategory === props.category
let showTourTip = showTour && onboardingTourStep === props.step.toString()
if (props.requireCard) {
const card = useAppSelector(getCurrentCard)
const isOnboardingCard = card ? card.title === OnboardingCardTitle : false
showTourTip = showTourTip && isOnboardingCard
}
const currentStep = parseInt(useAppSelector(getOnboardingTourStep), 10)
if (!showTourTip) {
return null
}
return (
<TourTip
screen={props.screen}
title={props.title}
punchOut={props.punchout}
step={currentStep}
tutorialCategory={props.category}
placement={props.placement}
className={props.classname}
imageURL={props.imageURL}
telemetryTag={props.telemetryTag}
skipCategoryFromBackdrop={true}
autoTour={true}
hideBackdrop={props.hideBackdrop}
singleTip={props.singleTip}
hideNavButtons={props.hideNavButtons}
/>
)
}
export default TourTipRenderer

View File

@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import './pulsating_dot.scss'
import {Coords} from '../tutorial_tour_tip/tutorial_tour_tip_backdrop'
type Props = {
targetRef?: React.RefObject<HTMLImageElement>;
className?: string;
onClick?: (e: React.MouseEvent) => void;
coords?: Coords;
}
const PulsatingDot = (props: Props):JSX.Element => {
let customStyles = {}
if (props?.coords) {
customStyles = {
transform: `translate(${props.coords?.x}px, ${props.coords?.y}px)`,
}
}
let effectiveClassName = 'pulsating_dot'
if (props.onClick) {
effectiveClassName += ' pulsating_dot-clickable'
}
if (props.className) {
effectiveClassName = effectiveClassName + ' ' + props.className
}
return (
<span
className={effectiveClassName}
onClick={props.onClick}
ref={props.targetRef}
style={{...customStyles}}
/>
)
}
export default PulsatingDot

View File

@ -0,0 +1,71 @@
.pulsating_dot {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
&-clickable {
cursor: pointer;
}
&,
&::before,
&::after {
width: 12px;
height: 12px;
background-color: #3db887;
border-radius: 50%;
}
&::before,
&::after {
position: absolute;
top: 0;
left: 0;
display: block;
content: '';
}
&::after {
animation: pulse1 2s ease 0s infinite;
}
&::before {
animation: pulse2 2s ease 0s infinite;
}
}
@keyframes pulse1 {
0% {
opacity: 1;
transform: scale(1);
}
80% {
opacity: 0;
transform: scale(2.5);
}
100% {
opacity: 0;
transform: scale(2.5);
}
}
@keyframes pulse2 {
0% {
opacity: 1;
transform: scale(1);
}
30% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(2.5);
}
}

View File

@ -86,7 +86,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
type="button"
>
<i
class="CompassIcon icon-refresh Icon Icon--right"
class="CompassIcon icon-refresh undefined"
/>
</button>
</div>
@ -95,10 +95,10 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
title="Copy link"
type="button"
>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
<span>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
Copy link
</span>
</button>
@ -196,7 +196,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
type="button"
>
<i
class="CompassIcon icon-refresh Icon Icon--right"
class="CompassIcon icon-refresh undefined"
/>
</button>
</div>
@ -205,10 +205,10 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
title="Copy link"
type="button"
>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
<span>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
Copied!
</span>
</button>
@ -306,7 +306,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Regene
type="button"
>
<i
class="CompassIcon icon-refresh Icon Icon--right"
class="CompassIcon icon-refresh undefined"
/>
</button>
</div>
@ -315,10 +315,10 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Regene
title="Copy link"
type="button"
>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
<span>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
Copy link
</span>
</button>
@ -416,7 +416,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard, and click switc
type="button"
>
<i
class="CompassIcon icon-refresh Icon Icon--right"
class="CompassIcon icon-refresh undefined"
/>
</button>
</div>
@ -425,10 +425,10 @@ exports[`src/components/shareBoard/shareBoard return shareBoard, and click switc
title="Copy link"
type="button"
>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
<span>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
Copy link
</span>
</button>
@ -526,7 +526,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoardComponent and cli
type="button"
>
<i
class="CompassIcon icon-refresh Icon Icon--right"
class="CompassIcon icon-refresh undefined"
/>
</button>
</div>
@ -535,10 +535,10 @@ exports[`src/components/shareBoard/shareBoard return shareBoardComponent and cli
title="Copy link"
type="button"
>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
<span>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
Copy link
</span>
</button>
@ -705,7 +705,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
type="button"
>
<i
class="CompassIcon icon-refresh Icon Icon--right"
class="CompassIcon icon-refresh undefined"
/>
</button>
</div>
@ -714,10 +714,10 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
title="Copy link"
type="button"
>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
<span>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
Copy link
</span>
</button>
@ -815,7 +815,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
type="button"
>
<i
class="CompassIcon icon-refresh Icon Icon--right"
class="CompassIcon icon-refresh undefined"
/>
</button>
</div>
@ -824,10 +824,10 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
title="Copy link"
type="button"
>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
<span>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
Copy link
</span>
</button>
@ -925,7 +925,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
type="button"
>
<i
class="CompassIcon icon-refresh Icon Icon--right"
class="CompassIcon icon-refresh undefined"
/>
</button>
</div>
@ -934,10 +934,10 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
title="Copy link"
type="button"
>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
<span>
<i
class="CompassIcon icon-content-copy CompassIcon"
/>
Copy link
</span>
</button>

View File

@ -6,14 +6,14 @@ exports[`src/components/shareBoard/shareBoard should match snapshot 1`] = `
class="ShareBoardButton"
>
<button
class="Button emphasis--secondary size--medium"
class="Button emphasis--primary size--medium"
title="Share board"
type="button"
>
<i
class="CompassIcon icon-globe CompassIcon"
/>
<span>
<i
class="CompassIcon icon-globe CompassIcon"
/>
Share
</span>
</button>

View File

@ -30,23 +30,12 @@
.input-container {
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
width: 400px;
flex: 1;
margin-right: 12px;
height: 40px;
}
.IconButton {
height: 100%;
width: 40px;
}
.icon-refresh {
margin: 4px;
font-size: 18px;
}
.icon-content-copy {
margin-right: 4px;
font-size: 18px;
align-items: center;
display: flex;
padding: 0 4px 0 16px;
}
.shareUrl {
@ -55,7 +44,8 @@
overflow: hidden;
text-overflow: ellipsis;
margin: auto 4px;
padding: 0 16px;
padding: 0 4px;
border-radius: 4px;
:hover {
background-color: rgb(var(--center-channel-color-rgb), 0.1);

View File

@ -134,14 +134,13 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
title={intl.formatMessage({id: 'ShareBoard.regenerate', defaultMessage: 'Regenerate token'})}
>
<IconButton
size='small'
onClick={onRegenerateToken}
icon={
<CompassIcon
icon='refresh'
className='Icon Icon--right'
/>}
title={intl.formatMessage({id: 'ShareBoard.regenerate', defaultMessage: 'Regenerate token'})}
className='IconButton--large'
/>
</Tooltip>
</div>
@ -149,16 +148,18 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
emphasis='secondary'
size='medium'
title='Copy link'
icon={
<CompassIcon
icon='content-copy'
className='CompassIcon'
/>
}
onClick={() => {
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ShareLinkPublicCopy, {board: props.boardId})
Utils.copyTextToClipboard(shareUrl.toString())
setWasCopied(true)
}}
>
<CompassIcon
icon='content-copy'
className='CompassIcon'
/>
{wasCopied &&
<FormattedMessage
id='ShareBoard.copiedLink'

View File

@ -1,20 +1,4 @@
.ShareBoardButton {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 8px;
> .Button {
margin-top: 36px;
padding: 3px 10px;
background-color: rgb(var(--button-bg-rgb));
color: rgb(var(--button-color-rgb));
border-radius: 5px;
&:hover {
background-color: rgba(var(--button-bg-rgb), 0.8);
}
}
margin-top: 38px;
}

View File

@ -24,16 +24,18 @@ const ShareBoardButton = (props: Props) => {
<Button
title='Share board'
size='medium'
emphasis='secondary'
emphasis='primary'
icon={
<CompassIcon
icon='globe'
className='CompassIcon'
/>
}
onClick={() => {
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ShareBoardOpenModal, {board: props.boardId})
setShowShareDialog(!showShareDialog)
}}
>
<CompassIcon
icon='globe'
className='CompassIcon'
/>
<FormattedMessage
id='CenterPanel.Share'
defaultMessage='Share'

Some files were not shown because too many files have changed in this diff Show More