mirror of
https://github.com/mattermost/focalboard.git
synced 2025-03-29 21:01:01 +02:00
Ported view limits to main (#3252)
* Ported view limits to main * lint fix * Added tests * Added tests * Fixed a server test * fixed webapp test * fixed webapp test * fixing some tests * implement check when duplicating views * Fixed webapp tests * Fixed webapp tests * Fixed webapp tests * Trying without race test * Lets race again * Made error descriptive * Minor improvements * Updates snapshots for changed alt text * Updates snapshots for changed alt text Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
This commit is contained in:
parent
b40452e4ca
commit
2ac56dbfba
mattermost-plugin
server
webapp
i18n
src
boardCloudLimits
components
boardTemplateSelector
centerPanel.test.tsxshareBoard
viewHeader
viewLImitDialog
__snapshots__
viewLimitDialog.scssviewLimitDialog.test.tsxviewLimitDialog.tsxviewLimitDialogWrapper.scssviewLimitDialogWrapper.test.tsxviewLimitDialogWrapper.tsxstore
types
utils.tswidgets/notificationBox
static
@ -109,7 +109,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, p.API)
|
||||
layeredStore, err2 := mattermostauthlayer.New(cfg.DBType, sqlDB, db, logger, p.API, client)
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("error initializing the DB: %w", err2)
|
||||
}
|
||||
|
@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {Post} from 'mattermost-redux/types/posts'
|
||||
|
||||
const PostTypeCloudUpgradeNudge = (props: {post: Post}): JSX.Element => {
|
||||
const ctaHandler = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const windowAny = (window as any)
|
||||
windowAny?.openPricingModal()()
|
||||
}
|
||||
|
||||
// custom post type doesn't support styling via CSS stylesheet.
|
||||
// Only styled components or react styles work there.
|
||||
const ctaContainerStyle = {
|
||||
padding: '12px',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
border: '1px solid rgba(63, 67, 80, 0.16)',
|
||||
borderLeft: '6px solid var(--link-color)',
|
||||
width: 'max-content',
|
||||
margin: '10px 0',
|
||||
}
|
||||
|
||||
const ctaBtnStyle = {
|
||||
background: 'var(--link-color)',
|
||||
color: 'var(--center-channel-bg)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '8px 20px',
|
||||
fontWeight: 600,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='PostTypeCloudUpgradeNudge'>
|
||||
<span>{props.post.message}</span>
|
||||
<div
|
||||
style={ctaContainerStyle}
|
||||
>
|
||||
<button
|
||||
onClick={ctaHandler}
|
||||
style={ctaBtnStyle}
|
||||
>
|
||||
{'View upgrade options'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostTypeCloudUpgradeNudge
|
@ -52,6 +52,7 @@ import ErrorBoundary from './error_boundary'
|
||||
import {PluginRegistry} from './types/mattermost-webapp'
|
||||
|
||||
import './plugin.scss'
|
||||
import CloudUpgradeNudge from "./components/cloudUpgradeNudge/cloudUpgradeNudge";
|
||||
|
||||
function getSubpath(siteURL: string): string {
|
||||
const url = new URL(siteURL)
|
||||
@ -281,6 +282,7 @@ export default class Plugin {
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CARD_LIMIT_TIMESTAMP}`, (e: any) => wsClient.updateCardLimitTimestampHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data))
|
||||
this.registry?.registerPostTypeComponent('custom_cloud_upgrade_nudge', CloudUpgradeNudge)
|
||||
this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => {
|
||||
let preferences
|
||||
try {
|
||||
|
@ -34,6 +34,8 @@ const (
|
||||
ErrorNoTeamMessage = "No team"
|
||||
)
|
||||
|
||||
var errAPINotSupportedInStandaloneMode = errors.New("API not supported in standalone mode")
|
||||
|
||||
type PermissionError struct {
|
||||
msg string
|
||||
}
|
||||
@ -168,6 +170,7 @@ func (a *API) RegisterRoutes(r *mux.Router) {
|
||||
|
||||
// limits
|
||||
apiv2.HandleFunc("/limits", a.sessionRequired(a.handleCloudLimits)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/notifyadminupgrade", a.sessionRequired(a.handleNotifyAdminUpgrade)).Methods(http.MethodPost)
|
||||
|
||||
// System APIs
|
||||
r.HandleFunc("/hello", a.handleHello).Methods("GET")
|
||||
@ -4230,6 +4233,37 @@ func (a *API) handleHello(w http.ResponseWriter, r *http.Request) {
|
||||
stringResponse(w, "Hello")
|
||||
}
|
||||
|
||||
func (a *API) handleNotifyAdminUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /api/v2/teams/{teamID}/notifyadminupgrade handleNotifyAdminUpgrade
|
||||
//
|
||||
// Notifies admins for upgrade request.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
if !a.MattermostAuth {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusNotFound, "", errAPINotSupportedInStandaloneMode)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
|
||||
if err := a.app.NotifyPortalAdminsUpgradeRequest(teamID); err != nil {
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
}
|
||||
}
|
||||
|
||||
// Response helpers
|
||||
|
||||
func (a *API) errorResponse(w http.ResponseWriter, api string, code int, message string, sourceError error) {
|
||||
|
@ -180,6 +180,28 @@ func (a *App) InsertBlock(block model.Block, modifiedByID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) isWithinViewsLimit(boardID string, block model.Block) (bool, error) {
|
||||
limits, err := a.GetBoardsCloudLimits()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if limits.Views == model.LimitUnlimited {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
views, err := a.store.GetBlocksWithParentAndType(boardID, block.ParentID, model.TypeView)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// < rather than <= because we'll be creating new view if this
|
||||
// check passes. When that view is created, the limit will be reached.
|
||||
// That's why we need to check for if existing + the being-created
|
||||
// view doesn't exceed the limit.
|
||||
return len(views) < limits.Views, nil
|
||||
}
|
||||
|
||||
func (a *App) InsertBlocks(blocks []model.Block, modifiedByID string, allowNotifications bool) ([]model.Block, error) {
|
||||
if len(blocks) == 0 {
|
||||
return []model.Block{}, nil
|
||||
@ -200,6 +222,20 @@ func (a *App) InsertBlocks(blocks []model.Block, modifiedByID string, allowNotif
|
||||
|
||||
needsNotify := make([]model.Block, 0, len(blocks))
|
||||
for i := range blocks {
|
||||
// this check is needed to whitelist inbuilt template
|
||||
// initialization. They do contain more than 5 views per board.
|
||||
if boardID != "0" && blocks[i].Type == model.TypeView {
|
||||
withinLimit, err := a.isWithinViewsLimit(board.ID, blocks[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !withinLimit {
|
||||
a.logger.Info("views limit reached on board", mlog.String("board_id", blocks[i].ParentID), mlog.String("team_id", board.TeamID))
|
||||
return nil, ErrViewsLimitReached
|
||||
}
|
||||
}
|
||||
|
||||
err := a.store.InsertBlock(&blocks[i], modifiedByID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -4,6 +4,8 @@ import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@ -24,7 +26,7 @@ func TestInsertBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenerio", func(t *testing.T) {
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
@ -35,7 +37,7 @@ func TestInsertBlock(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenerio", func(t *testing.T) {
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
@ -50,7 +52,7 @@ func TestPatchBlocks(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("patchBlocks success scenerio", func(t *testing.T) {
|
||||
t.Run("patchBlocks success scenario", func(t *testing.T) {
|
||||
blockPatches := model.BlockPatchBatch{
|
||||
BlockIDs: []string{"block1"},
|
||||
BlockPatches: []model.BlockPatch{
|
||||
@ -68,7 +70,7 @@ func TestPatchBlocks(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("patchBlocks error scenerio", func(t *testing.T) {
|
||||
t.Run("patchBlocks error scenario", func(t *testing.T) {
|
||||
blockPatches := model.BlockPatchBatch{BlockIDs: []string{}}
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{}).Return(nil, sql.ErrNoRows)
|
||||
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
|
||||
@ -115,7 +117,7 @@ func TestDeleteBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenerio", func(t *testing.T) {
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
board := &model.Board{ID: boardID}
|
||||
block := model.Block{
|
||||
@ -130,7 +132,7 @@ func TestDeleteBlock(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenerio", func(t *testing.T) {
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
board := &model.Board{ID: boardID}
|
||||
block := model.Block{
|
||||
@ -149,7 +151,7 @@ func TestUndeleteBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenerio", func(t *testing.T) {
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
board := &model.Board{ID: boardID}
|
||||
block := model.Block{
|
||||
@ -168,7 +170,7 @@ func TestUndeleteBlock(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenerio", func(t *testing.T) {
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
block := model.Block{
|
||||
ID: "block-id",
|
||||
}
|
||||
@ -182,3 +184,226 @@ func TestUndeleteBlock(t *testing.T) {
|
||||
require.Error(t, err, "error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsWithinViewsLimit(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
fakeLicense := &mmModel.License{
|
||||
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
|
||||
}
|
||||
|
||||
t.Run("within views limit", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]model.Block{{}}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("view limit exactly reached", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(1),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]model.Block{{}}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("view limit already exceeded", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]model.Block{{}, {}, {}}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("creating first view", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]model.Block{}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("is not a cloud SKU so limits don't apply", func(t *testing.T) {
|
||||
nonCloudLicense := &mmModel.License{
|
||||
Features: &mmModel.Features{Cloud: mmModel.NewBool(false)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(nonCloudLicense)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, withinLimits)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInsertBlocks(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(&block, "user-id-1").Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
_, err := th.App.InsertBlocks([]model.Block{block}, "user-id-1", false)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(&block, "user-id-1").Return(blockError{"error"})
|
||||
_, err := th.App.InsertBlocks([]model.Block{block}, "user-id-1", false)
|
||||
require.Error(t, err, "error")
|
||||
})
|
||||
|
||||
t.Run("create view within limits", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(&block, "user-id-1").Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
|
||||
// setting up mocks for limits
|
||||
fakeLicense := &mmModel.License{
|
||||
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]model.Block{{}}, nil)
|
||||
|
||||
_, err := th.App.InsertBlocks([]model.Block{block}, "user-id-1", false)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("create view exceeding limits", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(&block, "user-id-1").Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
|
||||
// setting up mocks for limits
|
||||
fakeLicense := &mmModel.License{
|
||||
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]model.Block{{}, {}}, nil)
|
||||
|
||||
_, err := th.App.InsertBlocks([]model.Block{block}, "user-id-1", false)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("creating multiple views, reaching limit in the process", func(t *testing.T) {
|
||||
t.Skipf("Will be fixed soon")
|
||||
|
||||
boardID := testBoardID
|
||||
view1 := model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
|
||||
view2 := model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(&view1, "user-id-1").Return(nil).Times(2)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
// setting up mocks for limits
|
||||
fakeLicense := &mmModel.License{
|
||||
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense).Times(2)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil).Times(2)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil).Times(2)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil).Times(2)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]model.Block{{}}, nil).Times(2)
|
||||
|
||||
_, err := th.App.InsertBlocks([]model.Block{view1, view2}, "user-id-1", false)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
@ -7,6 +7,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
@ -254,3 +256,55 @@ func newErrBoardNotFoundInTemplateMap(id string) *errBoardNotFoundInTemplateMap
|
||||
func (eb *errBoardNotFoundInTemplateMap) Error() string {
|
||||
return fmt.Sprintf("board %q not found in template map", eb.id)
|
||||
}
|
||||
|
||||
func (a *App) NotifyPortalAdminsUpgradeRequest(teamID string) error {
|
||||
if a.pluginAPI == nil {
|
||||
return ErrNilPluginAPI
|
||||
}
|
||||
|
||||
team, err := a.store.GetTeam(teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ofWhat string
|
||||
if team == nil {
|
||||
ofWhat = "your organization"
|
||||
} else {
|
||||
ofWhat = team.Title
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("A member of %s has notified you to upgrade this workspace before the trial ends.", ofWhat)
|
||||
|
||||
page := 0
|
||||
getUsersOptions := &mmModel.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mmModel.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: page,
|
||||
}
|
||||
|
||||
for ; true; page++ {
|
||||
getUsersOptions.Page = page
|
||||
systemAdmins, appErr := a.pluginAPI.GetUsers(getUsersOptions)
|
||||
if appErr != nil {
|
||||
a.logger.Error("failed to fetch system admins", mlog.Int("page_size", getUsersOptions.PerPage), mlog.Int("page", page), mlog.Err(appErr))
|
||||
return appErr
|
||||
}
|
||||
|
||||
if len(systemAdmins) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
receiptUserIDs := []string{}
|
||||
for _, systemAdmin := range systemAdmins {
|
||||
receiptUserIDs = append(receiptUserIDs, systemAdmin.Id)
|
||||
}
|
||||
|
||||
if err := a.store.SendMessage(message, "custom_cloud_upgrade_nudge", receiptUserIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -7,6 +7,9 @@ import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/plugin/plugintest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@ -640,3 +643,123 @@ func TestContainsLimitedBlocks(t *testing.T) {
|
||||
require.False(t, containsLimitedBlocks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotifyPortalAdminsUpgradeRequest(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("should send message", func(t *testing.T) {
|
||||
pluginAPI := &plugintest.API{}
|
||||
|
||||
sysAdmin1 := &mmModel.User{
|
||||
Id: "michael-scott",
|
||||
Username: "Michael Scott",
|
||||
}
|
||||
|
||||
sysAdmin2 := &mmModel.User{
|
||||
Id: "dwight-schrute",
|
||||
Username: "Dwight Schrute",
|
||||
}
|
||||
|
||||
getUsersOptionsPage0 := &mmModel.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mmModel.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 0,
|
||||
}
|
||||
pluginAPI.On("GetUsers", getUsersOptionsPage0).Return([]*mmModel.User{sysAdmin1, sysAdmin2}, nil).Once()
|
||||
|
||||
getUsersOptionsPage1 := &mmModel.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mmModel.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 1,
|
||||
}
|
||||
pluginAPI.On("GetUsers", getUsersOptionsPage1).Return([]*mmModel.User{}, nil).Once()
|
||||
|
||||
th.App.pluginAPI = pluginAPI
|
||||
|
||||
team := &model.Team{
|
||||
Title: "Dunder Mifflin",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetTeam("team-id-1").Return(team, nil)
|
||||
th.Store.EXPECT().SendMessage(gomock.Any(), "custom_cloud_upgrade_nudge", gomock.Any()).Return(nil).Times(1)
|
||||
|
||||
err := th.App.NotifyPortalAdminsUpgradeRequest("team-id-1")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("no sys admins found", func(t *testing.T) {
|
||||
pluginAPI := &plugintest.API{}
|
||||
|
||||
getUsersOptionsPage0 := &mmModel.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mmModel.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 0,
|
||||
}
|
||||
pluginAPI.On("GetUsers", getUsersOptionsPage0).Return([]*mmModel.User{}, nil).Once()
|
||||
|
||||
th.App.pluginAPI = pluginAPI
|
||||
|
||||
team := &model.Team{
|
||||
Title: "Dunder Mifflin",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetTeam("team-id-1").Return(team, nil)
|
||||
|
||||
err := th.App.NotifyPortalAdminsUpgradeRequest("team-id-1")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("iterate multiple pages", func(t *testing.T) {
|
||||
pluginAPI := &plugintest.API{}
|
||||
|
||||
sysAdmin1 := &mmModel.User{
|
||||
Id: "michael-scott",
|
||||
Username: "Michael Scott",
|
||||
}
|
||||
|
||||
sysAdmin2 := &mmModel.User{
|
||||
Id: "dwight-schrute",
|
||||
Username: "Dwight Schrute",
|
||||
}
|
||||
|
||||
getUsersOptionsPage0 := &mmModel.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mmModel.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 0,
|
||||
}
|
||||
pluginAPI.On("GetUsers", getUsersOptionsPage0).Return([]*mmModel.User{sysAdmin1}, nil).Once()
|
||||
|
||||
getUsersOptionsPage1 := &mmModel.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mmModel.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 1,
|
||||
}
|
||||
pluginAPI.On("GetUsers", getUsersOptionsPage1).Return([]*mmModel.User{sysAdmin2}, nil).Once()
|
||||
|
||||
getUsersOptionsPage2 := &mmModel.UserGetOptions{
|
||||
Active: true,
|
||||
Role: mmModel.SystemAdminRoleId,
|
||||
PerPage: 50,
|
||||
Page: 2,
|
||||
}
|
||||
pluginAPI.On("GetUsers", getUsersOptionsPage2).Return([]*mmModel.User{}, nil).Once()
|
||||
|
||||
th.App.pluginAPI = pluginAPI
|
||||
|
||||
team := &model.Team{
|
||||
Title: "Dunder Mifflin",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetTeam("team-id-1").Return(team, nil)
|
||||
th.Store.EXPECT().SendMessage(gomock.Any(), "custom_cloud_upgrade_nudge", gomock.Any()).Return(nil).Times(2)
|
||||
|
||||
err := th.App.NotifyPortalAdminsUpgradeRequest("team-id-1")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
@ -61,6 +61,8 @@ type User struct {
|
||||
// If the user is a guest or not
|
||||
// required: true
|
||||
IsGuest bool `json:"is_guest"`
|
||||
|
||||
Roles string `json:"roles"`
|
||||
}
|
||||
|
||||
// UserPropPatch is a user property patch
|
||||
|
@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
pluginapi "github.com/mattermost/mattermost-plugin-api"
|
||||
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/plugin"
|
||||
|
||||
@ -17,6 +19,11 @@ import (
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
var systemsBot = &mmModel.Bot{
|
||||
Username: mmModel.BotSystemBotUsername,
|
||||
DisplayName: "System",
|
||||
}
|
||||
|
||||
type NotSupportedError struct {
|
||||
msg string
|
||||
}
|
||||
@ -32,16 +39,25 @@ type MattermostAuthLayer struct {
|
||||
mmDB *sql.DB
|
||||
logger *mlog.Logger
|
||||
pluginAPI plugin.API
|
||||
client *pluginapi.Client
|
||||
}
|
||||
|
||||
// New creates a new SQL implementation of the store.
|
||||
func New(dbType string, db *sql.DB, store store.Store, logger *mlog.Logger, pluginAPI plugin.API) (*MattermostAuthLayer, error) {
|
||||
func New(
|
||||
dbType string,
|
||||
db *sql.DB,
|
||||
store store.Store,
|
||||
logger *mlog.Logger,
|
||||
pluginAPI plugin.API,
|
||||
client *pluginapi.Client,
|
||||
) (*MattermostAuthLayer, error) {
|
||||
layer := &MattermostAuthLayer{
|
||||
Store: store,
|
||||
dbType: dbType,
|
||||
mmDB: db,
|
||||
logger: logger,
|
||||
pluginAPI: pluginAPI,
|
||||
client: client,
|
||||
}
|
||||
|
||||
return layer, nil
|
||||
@ -381,6 +397,7 @@ func mmUserToFbUser(mmUser *mmModel.User) model.User {
|
||||
DeleteAt: mmUser.DeleteAt,
|
||||
IsBot: mmUser.IsBot,
|
||||
IsGuest: mmUser.IsGuest(),
|
||||
Roles: mmUser.Roles,
|
||||
}
|
||||
}
|
||||
|
||||
@ -469,3 +486,51 @@ func (s *MattermostAuthLayer) GetLicense() *mmModel.License {
|
||||
func (s *MattermostAuthLayer) GetCloudLimits() (*mmModel.ProductLimits, error) {
|
||||
return s.pluginAPI.GetCloudLimits()
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) getSystemBotID() (string, error) {
|
||||
botID, err := s.client.Bot.EnsureBot(systemsBot)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to ensure system bot", mlog.String("username", systemsBot.Username), mlog.Err(err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
return botID, nil
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) SendMessage(message, postType string, receipts []string) error {
|
||||
botID, err := s.getSystemBotID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, receipt := range receipts {
|
||||
channel, err := s.pluginAPI.GetDirectChannel(botID, receipt)
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"failed to get DM channel between system bot and user for receipt",
|
||||
mlog.String("receipt", receipt),
|
||||
mlog.String("user_id", receipt),
|
||||
mlog.Err(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
post := &mmModel.Post{
|
||||
Message: message,
|
||||
UserId: botID,
|
||||
ChannelId: channel.Id,
|
||||
Type: postType,
|
||||
}
|
||||
|
||||
if _, err := s.pluginAPI.CreatePost(post); err != nil {
|
||||
s.logger.Error(
|
||||
"failed to send message to receipt from SendMessage",
|
||||
mlog.String("receipt", receipt),
|
||||
mlog.Err(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -1263,6 +1263,20 @@ func (mr *MockStoreMockRecorder) SearchUsersByTeam(arg0, arg1 interface{}) *gomo
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchUsersByTeam", reflect.TypeOf((*MockStore)(nil).SearchUsersByTeam), arg0, arg1)
|
||||
}
|
||||
|
||||
// SendMessage mocks base method.
|
||||
func (m *MockStore) SendMessage(arg0, arg1 string, arg2 []string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SendMessage", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SendMessage indicates an expected call of SendMessage.
|
||||
func (mr *MockStoreMockRecorder) SendMessage(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMessage", reflect.TypeOf((*MockStore)(nil).SendMessage), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// SetSystemSetting mocks base method.
|
||||
func (m *MockStore) SetSystemSetting(arg0, arg1 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -736,6 +736,11 @@ func (s *SQLStore) SearchUsersByTeam(teamID string, searchQuery string) ([]*mode
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SendMessage(message string, postType string, receipts []string) error {
|
||||
return s.sendMessage(s.db, message, postType, receipts)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SetSystemSetting(key string, value string) error {
|
||||
return s.setSystemSetting(s.db, key, value)
|
||||
|
||||
|
@ -3,6 +3,7 @@ package sqlstore
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
@ -13,6 +14,10 @@ import (
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
var (
|
||||
errUnsupportedOperation = errors.New("unsupported operation")
|
||||
)
|
||||
|
||||
type UserNotFoundError struct {
|
||||
id string
|
||||
}
|
||||
@ -265,3 +270,7 @@ func (s *SQLStore) patchUserProps(db sq.BaseRunner, userID string, patch model.U
|
||||
|
||||
return s.updateUser(db, user)
|
||||
}
|
||||
|
||||
func (s *SQLStore) sendMessage(db sq.BaseRunner, message, postType string, receipts []string) error {
|
||||
return errUnsupportedOperation
|
||||
}
|
||||
|
@ -150,4 +150,6 @@ type Store interface {
|
||||
|
||||
GetLicense() *mmModel.License
|
||||
GetCloudLimits() (*mmModel.ProductLimits, error)
|
||||
|
||||
SendMessage(message, postType string, receipts []string) error
|
||||
}
|
||||
|
@ -337,5 +337,12 @@
|
||||
"tutorial_tip.got_it": "Got it",
|
||||
"tutorial_tip.ok": "Next",
|
||||
"tutorial_tip.out": "Opt out of these tips.",
|
||||
"tutorial_tip.seen": "Seen this before?"
|
||||
"tutorial_tip.seen": "Seen this before?",
|
||||
"ViewLimitDialog.Heading": "Views per board limit reached",
|
||||
"ViewLimitDialog.Subtext.RegularUser": "Notify your Admin to upgrade to our Professional or Enterprise plan to have unlimited views per boards, unlimited cards, and more.",
|
||||
"ViewLimitDialog.PrimaryButton.Title.RegularUser": "Notify Admin",
|
||||
"ViewLimitDialog.Subtext.Admin": "Upgrade to our Professional or Enterprise plan to have unlimited views per boards, unlimited cards and more.",
|
||||
"ViewLimitDialog.Subtext.Admin.PricingPageLink": "Learn more about our plans.",
|
||||
"ViewLimitDialog.PrimaryButton.Title.Admin": "Upgrade",
|
||||
"ViewLimitDialog.UpgradeImg.AltText": "upgrade image"
|
||||
}
|
||||
|
11
webapp/src/boardCloudLimits/index.ts
Normal file
11
webapp/src/boardCloudLimits/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export const LimitUnlimited = 0
|
||||
|
||||
export interface BoardsCloudLimits {
|
||||
cards: number
|
||||
used_cards: number
|
||||
card_limit_timestamp: number
|
||||
views: number
|
||||
}
|
@ -159,6 +159,11 @@ describe('components/boardTemplateSelector/boardTemplateSelectorPreview', () =>
|
||||
dateDisplayPropertyId: 'global-id-5',
|
||||
}],
|
||||
},
|
||||
limits: {
|
||||
limits: {
|
||||
views: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
store = mockStateStore([], state)
|
||||
})
|
||||
|
@ -135,6 +135,11 @@ describe('components/centerPanel', () => {
|
||||
[card2.id]: [comment2],
|
||||
},
|
||||
},
|
||||
imits: {
|
||||
limits: {
|
||||
views: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
const store = mockStateStore([], state)
|
||||
beforeAll(() => {
|
||||
|
@ -96,7 +96,16 @@ card3.id = 'card3'
|
||||
card3.title = 'card-3'
|
||||
card3.boardId = fakeBoard.id
|
||||
|
||||
const me: IUser = {id: 'user-id-1', username: 'username_1', email: '', props: {}, create_at: 0, update_at: 0, is_bot: false, roles: 'system_user'}
|
||||
const me: IUser = {
|
||||
id: 'user-id-1',
|
||||
username: 'username_1',
|
||||
email: '',
|
||||
props: {},
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
|
||||
const categoryAttribute1 = TestBlockFactory.createCategoryBoards()
|
||||
categoryAttribute1.name = 'Category 1'
|
||||
|
@ -66,6 +66,11 @@ describe('components/viewHeader/viewHeader', () => {
|
||||
},
|
||||
current: 'boardView',
|
||||
},
|
||||
limits: {
|
||||
limits: {
|
||||
views: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
const store = mockStateStore([], state)
|
||||
test('return viewHeader', () => {
|
||||
|
@ -34,6 +34,10 @@ import AddViewTourStep from '../onboardingTour/addView/add_view'
|
||||
import {getCurrentCard} from '../../store/cards'
|
||||
import BoardPermissionGate from '../permissions/boardPermissionGate'
|
||||
|
||||
import {getLimits} from "../../store/limits"
|
||||
import {LimitUnlimited} from "../../boardCloudLimits"
|
||||
import ViewLimitModalWrapper from "../viewLImitDialog/viewLimitDialogWrapper"
|
||||
|
||||
import NewCardButton from './newCardButton'
|
||||
import ViewHeaderPropertiesMenu from './viewHeaderPropertiesMenu'
|
||||
import ViewHeaderGroupByMenu from './viewHeaderGroupByMenu'
|
||||
@ -112,6 +116,20 @@ const ViewHeader = (props: Props) => {
|
||||
|
||||
const showAddViewTourStep = showTourBaseCondition && delayComplete
|
||||
|
||||
const [showViewLimitDialog, setShowViewLimitDialog] = useState<boolean>(false)
|
||||
|
||||
const limits = useAppSelector(getLimits)
|
||||
|
||||
const allowCreateView = (): boolean => {
|
||||
if (limits && (limits.views === LimitUnlimited || views.length < limits.views)) {
|
||||
setShowViewLimitDialog(false)
|
||||
return true
|
||||
}
|
||||
|
||||
setShowViewLimitDialog(true)
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='ViewHeader'>
|
||||
<div className='viewSelector'>
|
||||
@ -138,6 +156,7 @@ const ViewHeader = (props: Props) => {
|
||||
activeView={activeView}
|
||||
views={views}
|
||||
readonly={props.readonly || !canEditBoardProperties}
|
||||
allowCreateView={allowCreateView}
|
||||
/>
|
||||
</MenuWrapper>
|
||||
{showAddViewTourStep && <AddViewTourStep/>}
|
||||
@ -230,6 +249,11 @@ const ViewHeader = (props: Props) => {
|
||||
/>
|
||||
</BoardPermissionGate>
|
||||
</>}
|
||||
|
||||
<ViewLimitModalWrapper
|
||||
show={showViewLimitDialog}
|
||||
onClose={() => setShowViewLimitDialog(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -0,0 +1,160 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/viewLimitDialog/ViewLiimitDialog show notify upgrade button for non sys admin user 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Dialog dialog-back ViewLimitDialog"
|
||||
>
|
||||
<div
|
||||
class="backdrop"
|
||||
/>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="toolbar"
|
||||
>
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
class="IconButton size--medium"
|
||||
title="Close dialog"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="toolbar--right"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="ViewLimitDialog_body"
|
||||
>
|
||||
<img
|
||||
alt="upgrade image"
|
||||
src="test-file-stub"
|
||||
/>
|
||||
<h2
|
||||
class="header text-heading5"
|
||||
>
|
||||
Views per board limit reached
|
||||
</h2>
|
||||
<p
|
||||
class="text-heading1"
|
||||
>
|
||||
Notify your Admin to upgrade to our Professional or Enterprise plan to have unlimited views per boards, unlimited cards, and more.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="ViewLimitDialog_footer"
|
||||
>
|
||||
<button
|
||||
class="Button size--medium cancel"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="Button emphasis--primary size--medium primaryAction"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Notify Admin
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/viewLimitDialog/ViewLiimitDialog show upgrade button for sys admin user 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Dialog dialog-back ViewLimitDialog"
|
||||
>
|
||||
<div
|
||||
class="backdrop"
|
||||
/>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="toolbar"
|
||||
>
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
class="IconButton size--medium"
|
||||
title="Close dialog"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="toolbar--right"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="ViewLimitDialog_body"
|
||||
>
|
||||
<img
|
||||
alt="upgrade image"
|
||||
src="test-file-stub"
|
||||
/>
|
||||
<h2
|
||||
class="header text-heading5"
|
||||
>
|
||||
Views per board limit reached
|
||||
</h2>
|
||||
<p
|
||||
class="text-heading1"
|
||||
>
|
||||
Upgrade to our Professional or Enterprise plan to have unlimited views per boards, unlimited cards, and more.
|
||||
<a
|
||||
href="https://mattermost.com/pricing/"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about our plans.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="ViewLimitDialog_footer"
|
||||
>
|
||||
<button
|
||||
class="Button size--medium cancel"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="Button emphasis--primary size--medium primaryAction"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Upgrade
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
187
webapp/src/components/viewLImitDialog/__snapshots__/viewLimitDialogWrapper.test.tsx.snap
Normal file
187
webapp/src/components/viewLImitDialog/__snapshots__/viewLimitDialogWrapper.test.tsx.snap
Normal file
@ -0,0 +1,187 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/viewLimitDialog/ViewL]imitDialog show notify admin confirmation msg 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Dialog dialog-back ViewLimitDialog"
|
||||
>
|
||||
<div
|
||||
class="backdrop"
|
||||
/>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="toolbar"
|
||||
>
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
class="IconButton size--medium"
|
||||
title="Close dialog"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="toolbar--right"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="ViewLimitDialog_body"
|
||||
>
|
||||
<img
|
||||
alt="upgrade image"
|
||||
src="test-file-stub"
|
||||
/>
|
||||
<h2
|
||||
class="header text-heading5"
|
||||
>
|
||||
Views per board limit reached
|
||||
</h2>
|
||||
<p
|
||||
class="text-heading1"
|
||||
>
|
||||
Notify your Admin to upgrade to our Professional or Enterprise plan to have unlimited views per boards, unlimited cards, and more.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="ViewLimitDialog_footer"
|
||||
>
|
||||
<button
|
||||
class="Button size--medium cancel"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="Button emphasis--primary size--medium primaryAction"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Notify Admin
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="NotificationBox ViewLimitSuccessNotify"
|
||||
>
|
||||
<div
|
||||
class="NotificationBox__icon"
|
||||
>
|
||||
<svg
|
||||
class="CheckIcon Icon"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polyline
|
||||
points="20,60 40,80 80,40"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="content"
|
||||
>
|
||||
<p
|
||||
class="title"
|
||||
>
|
||||
Your admin has been notified
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/viewLimitDialog/ViewL]imitDialog show view limit dialog 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="Dialog dialog-back ViewLimitDialog"
|
||||
>
|
||||
<div
|
||||
class="backdrop"
|
||||
/>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="toolbar"
|
||||
>
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
class="IconButton size--medium"
|
||||
title="Close dialog"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="toolbar--right"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="ViewLimitDialog_body"
|
||||
>
|
||||
<img
|
||||
alt="upgrade image"
|
||||
src="test-file-stub"
|
||||
/>
|
||||
<h2
|
||||
class="header text-heading5"
|
||||
>
|
||||
Views per board limit reached
|
||||
</h2>
|
||||
<p
|
||||
class="text-heading1"
|
||||
>
|
||||
Notify your Admin to upgrade to our Professional or Enterprise plan to have unlimited views per boards, unlimited cards, and more.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="ViewLimitDialog_footer"
|
||||
>
|
||||
<button
|
||||
class="Button size--medium cancel"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="Button emphasis--primary size--medium primaryAction"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Notify Admin
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
51
webapp/src/components/viewLImitDialog/viewLimitDialog.scss
Normal file
51
webapp/src/components/viewLImitDialog/viewLimitDialog.scss
Normal file
@ -0,0 +1,51 @@
|
||||
.ViewLimitDialog > .wrapper > .dialog {
|
||||
color: rgb(var(--center-channel-color-rgb));
|
||||
width: 512px;
|
||||
height: max-content;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 32px rgba(var(--center-channel-color-rgb), 0.1);
|
||||
|
||||
.toolbar {
|
||||
flex-direction: row-reverse;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.ViewLimitDialog_body {
|
||||
padding: 0 32px 10px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 217px;
|
||||
}
|
||||
|
||||
.text-heading1 {
|
||||
font-weight: 400;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
a {
|
||||
color: rgb(var(--link-color-rgb));
|
||||
}
|
||||
}
|
||||
|
||||
.ViewLimitDialog_footer {
|
||||
display: flex;
|
||||
padding: 24px 32px;
|
||||
border-top: solid 1px rgba(var(--center-channel-color-rgb), 0.16);
|
||||
|
||||
.primaryAction {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
134
webapp/src/components/viewLImitDialog/viewLimitDialog.test.tsx
Normal file
134
webapp/src/components/viewLImitDialog/viewLimitDialog.test.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
|
||||
import {render, waitFor} from "@testing-library/react"
|
||||
|
||||
import {Provider as ReduxProvider} from "react-redux"
|
||||
|
||||
import {MemoryRouter} from "react-router-dom"
|
||||
|
||||
import userEvent from "@testing-library/user-event"
|
||||
|
||||
import {mocked} from "jest-mock"
|
||||
|
||||
import {mockStateStore, wrapDNDIntl} from "../../testUtils"
|
||||
import {TestBlockFactory} from "../../test/testBlockFactory"
|
||||
import {Board} from "../../blocks/board"
|
||||
|
||||
import client from "../../octoClient"
|
||||
|
||||
import {IAppWindow} from "../../types"
|
||||
|
||||
import {ViewLimitModal} from "./viewLimitDialog"
|
||||
|
||||
jest.mock('../../octoClient')
|
||||
const mockedOctoClient = mocked(client, true)
|
||||
|
||||
declare let window: IAppWindow
|
||||
|
||||
describe('components/viewLimitDialog/ViewLiimitDialog', () => {
|
||||
const board: Board = {
|
||||
...TestBlockFactory.createBoard(),
|
||||
id: 'board_id_1',
|
||||
}
|
||||
|
||||
const state = {
|
||||
users: {
|
||||
me: {
|
||||
id: 'user_id_1',
|
||||
username: 'Michael Scott',
|
||||
roles: 'system_user',
|
||||
},
|
||||
},
|
||||
boards: {
|
||||
boards: {
|
||||
[board.id]: board,
|
||||
},
|
||||
current: board.id,
|
||||
}
|
||||
}
|
||||
|
||||
const store = mockStateStore([], state)
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('show notify upgrade button for non sys admin user', async () => {
|
||||
const handleOnClose = jest.fn()
|
||||
const handleShowNotifyAdminSuccess = jest.fn()
|
||||
mockedOctoClient.notifyAdminUpgrade.mockResolvedValue()
|
||||
|
||||
const {container} = render(wrapDNDIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<ViewLimitModal
|
||||
onClose={handleOnClose}
|
||||
showNotifyAdminSuccess={handleShowNotifyAdminSuccess}
|
||||
/>
|
||||
</ReduxProvider>
|
||||
), {wrapper: MemoryRouter})
|
||||
expect(container).toMatchSnapshot()
|
||||
|
||||
const notifyBtn = container.querySelector('button.primaryAction')
|
||||
expect(notifyBtn).toBeDefined()
|
||||
expect(notifyBtn).not.toBeNull()
|
||||
expect(notifyBtn!.textContent).toBe('Notify Admin')
|
||||
userEvent.click(notifyBtn as Element)
|
||||
await waitFor(() => expect(handleShowNotifyAdminSuccess).toBeCalledTimes(1))
|
||||
|
||||
const cancelBtn = container.querySelector('button.cancel')
|
||||
expect(cancelBtn).toBeDefined()
|
||||
expect(cancelBtn).not.toBeNull()
|
||||
userEvent.click(cancelBtn as Element)
|
||||
|
||||
// on close called twice.
|
||||
// once when clicking on notify admin btn
|
||||
// and once when clicking on cancel btn
|
||||
expect(handleOnClose).toBeCalledTimes(2)
|
||||
})
|
||||
|
||||
test('show upgrade button for sys admin user', async () => {
|
||||
const handleOnClose = jest.fn()
|
||||
const handleShowNotifyAdminSuccess = jest.fn()
|
||||
mockedOctoClient.notifyAdminUpgrade.mockResolvedValue()
|
||||
|
||||
const handleOpenPricingModalEmbeddedFunc = jest.fn()
|
||||
const handleOpenPricingModal = () => handleOpenPricingModalEmbeddedFunc
|
||||
window.openPricingModal = handleOpenPricingModal
|
||||
|
||||
const localState = {
|
||||
...state,
|
||||
}
|
||||
|
||||
localState.users.me.roles = 'system_admin'
|
||||
const localStore = mockStateStore([], localState)
|
||||
|
||||
const {container} = render(wrapDNDIntl(
|
||||
<ReduxProvider store={localStore}>
|
||||
<ViewLimitModal
|
||||
onClose={handleOnClose}
|
||||
showNotifyAdminSuccess={handleShowNotifyAdminSuccess}
|
||||
/>
|
||||
</ReduxProvider>
|
||||
), {wrapper: MemoryRouter})
|
||||
expect(container).toMatchSnapshot()
|
||||
|
||||
const notifyBtn = container.querySelector('button.primaryAction')
|
||||
expect(notifyBtn).toBeDefined()
|
||||
expect(notifyBtn).not.toBeNull()
|
||||
expect(notifyBtn!.textContent).toBe('Upgrade')
|
||||
userEvent.click(notifyBtn as Element)
|
||||
expect(handleShowNotifyAdminSuccess).toBeCalledTimes(0)
|
||||
await waitFor(() => expect(handleOpenPricingModalEmbeddedFunc).toBeCalledTimes(1))
|
||||
|
||||
const cancelBtn = container.querySelector('button.cancel')
|
||||
expect(cancelBtn).toBeDefined()
|
||||
expect(cancelBtn).not.toBeNull()
|
||||
userEvent.click(cancelBtn as Element)
|
||||
|
||||
// on close called twice.
|
||||
// once when clicking on notify admin btn
|
||||
// and once when clicking on cancel btn
|
||||
expect(handleOnClose).toBeCalledTimes(2)
|
||||
})
|
||||
})
|
131
webapp/src/components/viewLImitDialog/viewLimitDialog.tsx
Normal file
131
webapp/src/components/viewLImitDialog/viewLimitDialog.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect} from 'react'
|
||||
|
||||
import './viewLimitDialog.scss'
|
||||
import {FormattedMessage, useIntl} from 'react-intl'
|
||||
|
||||
import Dialog from '../dialog'
|
||||
|
||||
import upgradeImage from '../../../static/upgrade.png'
|
||||
import {useAppSelector} from '../../store/hooks'
|
||||
import {getMe} from '../../store/users'
|
||||
import {Utils} from '../../utils'
|
||||
import Button from '../../widgets/buttons/button'
|
||||
import octoClient from '../../octoClient'
|
||||
import telemetryClient, {TelemetryActions, TelemetryCategory} from '../../telemetry/telemetryClient'
|
||||
import {getCurrentBoard} from '../../store/boards'
|
||||
|
||||
export type PublicProps = {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export type Props = PublicProps & {
|
||||
showNotifyAdminSuccess: () => void
|
||||
}
|
||||
|
||||
export const ViewLimitModal = (props: Props): JSX.Element => {
|
||||
const me = useAppSelector(getMe)
|
||||
const isAdmin = me ? Utils.isAdmin(me.roles) : false
|
||||
const intl = useIntl()
|
||||
|
||||
const board = useAppSelector(getCurrentBoard)
|
||||
|
||||
useEffect(() => {
|
||||
telemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ViewLimitReached, {board: board.id})
|
||||
}, [])
|
||||
|
||||
const heading = (
|
||||
<FormattedMessage
|
||||
id='ViewLimitDialog.Heading'
|
||||
defaultMessage='Views per board limit reached'
|
||||
/>
|
||||
)
|
||||
|
||||
const regularUserSubtext = (
|
||||
<FormattedMessage
|
||||
id='ViewLimitDialog.Subtext.RegularUser'
|
||||
defaultMessage='Notify your Admin to upgrade to our Professional or Enterprise plan to have unlimited views per boards, unlimited cards, and more.'
|
||||
/>
|
||||
)
|
||||
|
||||
const regularUserPrimaryButtonText = intl.formatMessage({id: 'ViewLimitDialog.PrimaryButton.Title.RegularUser', defaultMessage: 'Notify Admin'})
|
||||
|
||||
const adminSubtext = (
|
||||
<React.Fragment>
|
||||
<FormattedMessage
|
||||
id='ViewLimitDialog.Subtext.Admin'
|
||||
defaultMessage='Upgrade to our Professional or Enterprise plan to have unlimited views per boards, unlimited cards, and more.'
|
||||
/>
|
||||
<a
|
||||
href='https://mattermost.com/pricing/'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='ViewLimitDialog.Subtext.Admin.PricingPageLink'
|
||||
defaultMessage='Learn more about our plans.'
|
||||
/>
|
||||
</a>
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
const adminPrimaryButtonText = intl.formatMessage({id: 'ViewLimitDialog.PrimaryButton.Title.Admin', defaultMessage: 'Upgrade'})
|
||||
|
||||
const subtext = isAdmin ? adminSubtext : regularUserSubtext
|
||||
const primaryButtonText = isAdmin ? adminPrimaryButtonText : regularUserPrimaryButtonText
|
||||
|
||||
const handlePrimaryButtonAction = async () => {
|
||||
telemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ViewLimitCTAPerformed, {board: board.id})
|
||||
|
||||
if (isAdmin) {
|
||||
(window as any)?.openPricingModal()()
|
||||
} else {
|
||||
await octoClient.notifyAdminUpgrade()
|
||||
props.showNotifyAdminSuccess()
|
||||
}
|
||||
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className='ViewLimitDialog'
|
||||
onClose={props.onClose}
|
||||
>
|
||||
<div className='ViewLimitDialog_body'>
|
||||
<img
|
||||
src={Utils.buildURL(upgradeImage, true)}
|
||||
alt={intl.formatMessage({id: 'ViewLimitDialog.UpgradeImg.AltText', defaultMessage: 'upgrade image'})}
|
||||
/>
|
||||
<h2 className='header text-heading5'>
|
||||
{heading}
|
||||
</h2>
|
||||
<p className='text-heading1'>
|
||||
{subtext}
|
||||
</p>
|
||||
</div>
|
||||
<div className='ViewLimitDialog_footer'>
|
||||
<Button
|
||||
size={'medium'}
|
||||
className='cancel'
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='ConfirmationDialog.cancel-action'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size='medium'
|
||||
className='primaryAction'
|
||||
emphasis='primary'
|
||||
onClick={handlePrimaryButtonAction}
|
||||
>
|
||||
{primaryButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
.ViewLimitSuccessNotify {
|
||||
.NotificationBox__icon {
|
||||
font-size: 24px;
|
||||
|
||||
svg {
|
||||
stroke: var(--online-indicator);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
|
||||
import {render, waitFor} from "@testing-library/react"
|
||||
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
|
||||
import {Provider as ReduxProvider} from "react-redux"
|
||||
|
||||
import {MemoryRouter} from "react-router-dom"
|
||||
|
||||
import userEvent from "@testing-library/user-event"
|
||||
|
||||
import {mocked} from "jest-mock"
|
||||
|
||||
import {mockStateStore, wrapDNDIntl} from "../../testUtils"
|
||||
import {TestBlockFactory} from "../../test/testBlockFactory"
|
||||
import {Board} from "../../blocks/board"
|
||||
|
||||
import client from "../../octoClient"
|
||||
|
||||
import ViewLimitModalWrapper from "./viewLimitDialogWrapper"
|
||||
|
||||
jest.mock('../../octoClient')
|
||||
const mockedOctoClient = mocked(client, true)
|
||||
|
||||
describe('components/viewLimitDialog/ViewL]imitDialog', () => {
|
||||
const board: Board = {
|
||||
...TestBlockFactory.createBoard(),
|
||||
id: 'board_id_1',
|
||||
}
|
||||
|
||||
const state = {
|
||||
users: {
|
||||
me: {
|
||||
id: 'user_id_1',
|
||||
username: 'Michael Scott',
|
||||
roles: 'system_user',
|
||||
},
|
||||
},
|
||||
boards: {
|
||||
boards: {
|
||||
[board.id]: board,
|
||||
},
|
||||
current: board.id,
|
||||
}
|
||||
}
|
||||
|
||||
const store = mockStateStore([], state)
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('show view limit dialog', async () => {
|
||||
const handleOnClose = jest.fn()
|
||||
mockedOctoClient.notifyAdminUpgrade.mockResolvedValue()
|
||||
|
||||
const {container} = render(wrapDNDIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<ViewLimitModalWrapper
|
||||
onClose={handleOnClose}
|
||||
show={true}
|
||||
/>
|
||||
</ReduxProvider>
|
||||
), {wrapper: MemoryRouter})
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('show notify admin confirmation msg', async () => {
|
||||
const handleOnClose = jest.fn()
|
||||
mockedOctoClient.notifyAdminUpgrade.mockResolvedValue()
|
||||
|
||||
const {container} = render(wrapDNDIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<ViewLimitModalWrapper
|
||||
onClose={handleOnClose}
|
||||
show={true}
|
||||
/>
|
||||
</ReduxProvider>
|
||||
), {wrapper: MemoryRouter})
|
||||
|
||||
const notifyBtn = container.querySelector('button.primaryAction')
|
||||
expect(notifyBtn).toBeDefined()
|
||||
expect(notifyBtn).not.toBeNull()
|
||||
expect(notifyBtn!.textContent).toBe('Notify Admin')
|
||||
userEvent.click(notifyBtn as Element)
|
||||
await waitFor(() => expect(container.querySelector('.ViewLimitSuccessNotify')).toBeInTheDocument())
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useState} from 'react'
|
||||
|
||||
import {useIntl} from 'react-intl'
|
||||
|
||||
import CheckIcon from '../../widgets/icons/check'
|
||||
|
||||
import NotificationBox from "../../widgets/notificationBox/notificationBox"
|
||||
|
||||
import {PublicProps, ViewLimitModal} from './viewLimitDialog'
|
||||
|
||||
import './viewLimitDialogWrapper.scss'
|
||||
|
||||
type Props = PublicProps & {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
const ViewLimitModalWrapper = (props: Props): JSX.Element => {
|
||||
const intl = useIntl()
|
||||
const [showNotifyAdminSuccessMsg, setShowNotifyAdminSuccessMsg] = useState<boolean>(false)
|
||||
|
||||
const viewLimitDialog = (
|
||||
<ViewLimitModal
|
||||
onClose={props.onClose}
|
||||
showNotifyAdminSuccess={() => setShowNotifyAdminSuccessMsg(true)}
|
||||
/>
|
||||
)
|
||||
|
||||
const successNotificationBox = (
|
||||
<NotificationBox
|
||||
className='ViewLimitSuccessNotify'
|
||||
icon={<CheckIcon/>}
|
||||
title={intl.formatMessage({id: 'ViewLimitDialog.notifyAdmin.Success', defaultMessage: 'Your admin has been notified'})}
|
||||
onClose={() => setShowNotifyAdminSuccessMsg(false)}
|
||||
>
|
||||
{null}
|
||||
</NotificationBox>
|
||||
)
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{props.show && viewLimitDialog}
|
||||
{showNotifyAdminSuccessMsg && successNotificationBox}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default ViewLimitModalWrapper
|
@ -3,6 +3,7 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import {render} from '@testing-library/react'
|
||||
import 'isomorphic-fetch'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import React from 'react'
|
||||
import {Provider as ReduxProvider} from 'react-redux'
|
||||
@ -81,6 +82,7 @@ describe('/components/viewMenu', () => {
|
||||
activeView={activeView}
|
||||
views={views}
|
||||
readonly={false}
|
||||
allowCreateView={() => false}
|
||||
/>
|
||||
</Router>
|
||||
</ReduxProvider>,
|
||||
@ -102,6 +104,7 @@ describe('/components/viewMenu', () => {
|
||||
activeView={activeView}
|
||||
views={views}
|
||||
readonly={true}
|
||||
allowCreateView={() => false}
|
||||
/>
|
||||
</Router>
|
||||
</ReduxProvider>,
|
||||
@ -110,4 +113,32 @@ describe('/components/viewMenu', () => {
|
||||
const container = render(component)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('should check view limits', () => {
|
||||
const mockStore = configureStore([])
|
||||
const store = mockStore(state)
|
||||
|
||||
const mockedallowCreateView = jest.fn()
|
||||
mockedallowCreateView.mockReturnValue(false)
|
||||
|
||||
const component = wrapDNDIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<Router history={history}>
|
||||
<ViewMenu
|
||||
board={board}
|
||||
activeView={activeView}
|
||||
views={views}
|
||||
readonly={false}
|
||||
allowCreateView={mockedallowCreateView}
|
||||
/>
|
||||
</Router>
|
||||
</ReduxProvider>,
|
||||
)
|
||||
|
||||
const container = render(component)
|
||||
|
||||
const buttonElement = container.getByRole('button', {name: 'Duplicate view'})
|
||||
userEvent.click(buttonElement)
|
||||
expect(mockedallowCreateView).toBeCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
@ -29,6 +29,7 @@ type Props = {
|
||||
views: BoardView[],
|
||||
intl: IntlShape
|
||||
readonly: boolean
|
||||
allowCreateView: () => boolean
|
||||
}
|
||||
|
||||
const ViewMenu = (props: Props) => {
|
||||
@ -46,6 +47,11 @@ const ViewMenu = (props: Props) => {
|
||||
const handleDuplicateView = useCallback(() => {
|
||||
const {board, activeView} = props
|
||||
Utils.log('duplicateView')
|
||||
|
||||
if (!props.allowCreateView()) {
|
||||
return
|
||||
}
|
||||
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.DuplicateBoardView, {board: board.id, view: activeView.id})
|
||||
const currentViewId = activeView.id
|
||||
const newView = createBoardView(activeView)
|
||||
@ -92,6 +98,11 @@ const ViewMenu = (props: Props) => {
|
||||
const handleAddViewBoard = useCallback(() => {
|
||||
const {board, activeView, intl} = props
|
||||
Utils.log('addview-board')
|
||||
|
||||
if (!props.allowCreateView()) {
|
||||
return
|
||||
}
|
||||
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoardView, {board: board.id, view: activeView.id})
|
||||
const view = createBoardView()
|
||||
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board view'})
|
||||
@ -119,6 +130,11 @@ const ViewMenu = (props: Props) => {
|
||||
const {board, activeView, intl} = props
|
||||
|
||||
Utils.log('addview-table')
|
||||
|
||||
if (!props.allowCreateView()) {
|
||||
return
|
||||
}
|
||||
|
||||
const view = createBoardView()
|
||||
view.title = intl.formatMessage({id: 'View.NewTableTitle', defaultMessage: 'Table view'})
|
||||
view.fields.viewType = 'table'
|
||||
@ -149,6 +165,11 @@ const ViewMenu = (props: Props) => {
|
||||
const {board, activeView, intl} = props
|
||||
|
||||
Utils.log('addview-gallery')
|
||||
|
||||
if (!props.allowCreateView()) {
|
||||
return
|
||||
}
|
||||
|
||||
const view = createBoardView()
|
||||
view.title = intl.formatMessage({id: 'View.NewGalleryTitle', defaultMessage: 'Gallery view'})
|
||||
view.fields.viewType = 'gallery'
|
||||
@ -177,6 +198,12 @@ const ViewMenu = (props: Props) => {
|
||||
const {board, activeView, intl} = props
|
||||
|
||||
Utils.log('addview-calendar')
|
||||
|
||||
if (!props.allowCreateView()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const view = createBoardView()
|
||||
view.title = intl.formatMessage({id: 'View.NewCalendarTitle', defaultMessage: 'Calendar view'})
|
||||
view.fields.viewType = 'calendar'
|
||||
|
@ -72,7 +72,16 @@ card3.id = 'card3'
|
||||
card3.title = 'card-3'
|
||||
card3.boardId = fakeBoard.id
|
||||
|
||||
const me: IUser = {id: 'user-id-1', username: 'username_1', email: '', props: {}, create_at: 0, update_at: 0, is_bot: false, roles: 'system_user'}
|
||||
const me: IUser = {
|
||||
id: 'user-id-1',
|
||||
username: 'username_1',
|
||||
email: '',
|
||||
props: {},
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
is_bot: false,
|
||||
roles: 'system_user',
|
||||
}
|
||||
|
||||
const categoryAttribute1 = TestBlockFactory.createCategoryBoards()
|
||||
categoryAttribute1.name = 'Category 1'
|
||||
|
@ -13,6 +13,7 @@ import {Team} from './store/teams'
|
||||
import {Subscription} from './wsclient'
|
||||
import {PrepareOnboardingResponse} from './onboardingTour'
|
||||
import {Constants} from "./constants"
|
||||
|
||||
import {BoardsCloudLimits} from './boardsCloudLimits'
|
||||
|
||||
//
|
||||
@ -808,7 +809,14 @@ class OctoClient {
|
||||
return (await this.getJson(response, {})) as PrepareOnboardingResponse
|
||||
}
|
||||
|
||||
// limits
|
||||
async notifyAdminUpgrade(): Promise<void> {
|
||||
const path = `${this.teamsPath()}/notifyadminupgrade`
|
||||
await fetch(this.getBaseURL() + path, {
|
||||
headers: this.headers(),
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async getBoardsCloudLimits(): Promise<BoardsCloudLimits | undefined> {
|
||||
const path = '/api/v2/limits'
|
||||
const response = await fetch(this.getBaseURL() + path, {headers: this.headers()})
|
||||
|
@ -54,7 +54,7 @@ export const initialReadOnlyLoad = createAsyncThunk(
|
||||
if (!board) {
|
||||
throw new Error(ErrorId.InvalidReadOnlyBoard)
|
||||
}
|
||||
|
||||
|
||||
return {board, blocks}
|
||||
},
|
||||
)
|
||||
|
1
webapp/src/types/index.d.ts
vendored
1
webapp/src/types/index.d.ts
vendored
@ -8,6 +8,7 @@ export interface IAppWindow extends Window {
|
||||
msCrypto: Crypto
|
||||
openInNewBrowser?: ((href: string) => void) | null
|
||||
webkit?: {messageHandlers: {nativeApp?: {postMessage: <T>(message: T) => void}}}
|
||||
openPricingModal?: () => () => void
|
||||
}
|
||||
|
||||
// SuiteWindow documents all custom properties
|
||||
|
@ -26,6 +26,9 @@ const SpacerClass = 'octo-spacer'
|
||||
const HorizontalGripClass = 'HorizontalGrip'
|
||||
const base32Alphabet = 'ybndrfg8ejkmcpqxot1uwisza345h769'
|
||||
|
||||
export const SYSTEM_ADMIN_ROLE = 'system_admin'
|
||||
export const TEAM_ADMIN_ROLE = 'team_admin'
|
||||
|
||||
export type WSMessagePayloads = Block | Category | BoardCategoryWebsocketData | BoardType | BoardMember | null
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
@ -743,6 +746,26 @@ class Utils {
|
||||
|
||||
return bytes.toFixed(dp) + ' ' + units[u]
|
||||
}
|
||||
|
||||
static spaceSeparatedStringIncludes(item: string, spaceSeparated?: string): boolean {
|
||||
if (spaceSeparated) {
|
||||
const items = spaceSeparated?.split(' ')
|
||||
return items.includes(item)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static isSystemAdmin(roles: string): boolean {
|
||||
return Utils.spaceSeparatedStringIncludes(SYSTEM_ADMIN_ROLE, roles)
|
||||
}
|
||||
|
||||
static isTeamAdmin(roles: string): boolean {
|
||||
return Utils.spaceSeparatedStringIncludes(TEAM_ADMIN_ROLE, roles)
|
||||
}
|
||||
|
||||
static isAdmin(roles: string): boolean {
|
||||
return Utils.isSystemAdmin(roles) || Utils.isTeamAdmin(roles)
|
||||
}
|
||||
}
|
||||
|
||||
export {Utils, IDType}
|
||||
|
@ -0,0 +1,140 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`widgets/NotificationBox should match snapshot with close with tooltip 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="NotificationBox"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
>
|
||||
<p
|
||||
class="title"
|
||||
>
|
||||
title
|
||||
</p>
|
||||
CONTENT
|
||||
</div>
|
||||
<div
|
||||
class="octo-tooltip tooltip-top"
|
||||
data-tooltip="tooltip"
|
||||
>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`widgets/NotificationBox should match snapshot with close without tooltip 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="NotificationBox"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
>
|
||||
<p
|
||||
class="title"
|
||||
>
|
||||
title
|
||||
</p>
|
||||
CONTENT
|
||||
</div>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`widgets/NotificationBox should match snapshot with icon 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="NotificationBox"
|
||||
>
|
||||
<div
|
||||
class="NotificationBox__icon"
|
||||
>
|
||||
ICON
|
||||
</div>
|
||||
<div
|
||||
class="content"
|
||||
>
|
||||
<p
|
||||
class="title"
|
||||
>
|
||||
title
|
||||
</p>
|
||||
CONTENT
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`widgets/NotificationBox should match snapshot with icon and close with tooltip 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="NotificationBox"
|
||||
>
|
||||
<div
|
||||
class="NotificationBox__icon"
|
||||
>
|
||||
ICON
|
||||
</div>
|
||||
<div
|
||||
class="content"
|
||||
>
|
||||
<p
|
||||
class="title"
|
||||
>
|
||||
title
|
||||
</p>
|
||||
CONTENT
|
||||
</div>
|
||||
<div
|
||||
class="octo-tooltip tooltip-top"
|
||||
data-tooltip="tooltip"
|
||||
>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`widgets/NotificationBox should match snapshot without icon and close 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="NotificationBox"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
>
|
||||
<p
|
||||
class="title"
|
||||
>
|
||||
title
|
||||
</p>
|
||||
CONTENT
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
43
webapp/src/widgets/notificationBox/notificationBox.scss
Normal file
43
webapp/src/widgets/notificationBox/notificationBox.scss
Normal file
@ -0,0 +1,43 @@
|
||||
.NotificationBox {
|
||||
position: fixed;
|
||||
bottom: 52px;
|
||||
right: 32px;
|
||||
border-radius: 4px;
|
||||
background: rgb(var(--center-channel-bg-rgb));
|
||||
box-shadow: rgba(var(--center-channel-color-rgb), 0.1) 0 0 0 1px,
|
||||
rgba(var(--center-channel-color-rgb), 0.1) 0 2px 4px;
|
||||
display: flex;
|
||||
padding: 22px;
|
||||
width: 400px;
|
||||
z-index: 1000;
|
||||
|
||||
.NotificationBox__icon {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0;
|
||||
line-height: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
.IconButton {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.octo-tooltip {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
.IconButton {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
86
webapp/src/widgets/notificationBox/notificationBox.test.tsx
Normal file
86
webapp/src/widgets/notificationBox/notificationBox.test.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
// 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 '@testing-library/jest-dom'
|
||||
|
||||
import {wrapIntl} from '../../testUtils'
|
||||
|
||||
import NotificationBox from "./notificationBox"
|
||||
|
||||
|
||||
describe('widgets/NotificationBox', () => {
|
||||
beforeEach(() => {
|
||||
// Quick fix to disregard console error when unmounting a component
|
||||
console.error = jest.fn()
|
||||
document.execCommand = jest.fn()
|
||||
})
|
||||
|
||||
test('should match snapshot without icon and close', () => {
|
||||
const component = wrapIntl(
|
||||
<NotificationBox
|
||||
title='title'
|
||||
>
|
||||
{'CONTENT'}
|
||||
</NotificationBox>,
|
||||
)
|
||||
const {container} = render(component)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('should match snapshot with icon', () => {
|
||||
const component = wrapIntl(
|
||||
<NotificationBox
|
||||
title='title'
|
||||
icon='ICON'
|
||||
>
|
||||
{'CONTENT'}
|
||||
</NotificationBox>,
|
||||
)
|
||||
const {container} = render(component)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('should match snapshot with close without tooltip', () => {
|
||||
const component = wrapIntl(
|
||||
<NotificationBox
|
||||
title='title'
|
||||
onClose={() => null}
|
||||
>
|
||||
{'CONTENT'}
|
||||
</NotificationBox>,
|
||||
)
|
||||
const {container} = render(component)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('should match snapshot with close with tooltip', () => {
|
||||
const component = wrapIntl(
|
||||
<NotificationBox
|
||||
title='title'
|
||||
onClose={() => null}
|
||||
closeTooltip='tooltip'
|
||||
>
|
||||
{'CONTENT'}
|
||||
</NotificationBox>,
|
||||
)
|
||||
const {container} = render(component)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('should match snapshot with icon and close with tooltip', () => {
|
||||
const component = wrapIntl(
|
||||
<NotificationBox
|
||||
title='title'
|
||||
icon='ICON'
|
||||
onClose={() => null}
|
||||
closeTooltip='tooltip'
|
||||
>
|
||||
{'CONTENT'}
|
||||
</NotificationBox>,
|
||||
)
|
||||
const {container} = render(component)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
65
webapp/src/widgets/notificationBox/notificationBox.tsx
Normal file
65
webapp/src/widgets/notificationBox/notificationBox.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
|
||||
import {Utils} from '../../utils'
|
||||
|
||||
import IconButton from '../buttons/iconButton'
|
||||
import CloseIcon from '../icons/close'
|
||||
import Tooltip from '../tooltip'
|
||||
|
||||
import './notificationBox.scss'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
onClose?: () => void
|
||||
closeTooltip?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function renderClose(onClose?: () => void, closeTooltip?: string) {
|
||||
if (!onClose) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (closeTooltip) {
|
||||
return (
|
||||
<Tooltip title={closeTooltip}>
|
||||
<IconButton
|
||||
icon={<CloseIcon/>}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Tooltip>)
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={<CloseIcon/>}
|
||||
onClick={onClose}
|
||||
/>)
|
||||
}
|
||||
|
||||
function NotificationBox(props: Props): JSX.Element {
|
||||
const className = Utils.generateClassName({
|
||||
NotificationBox: true,
|
||||
[props.className || '']: Boolean(props.className),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{props.icon &&
|
||||
<div className='NotificationBox__icon'>
|
||||
{props.icon}
|
||||
</div>}
|
||||
<div className='content'>
|
||||
<p className='title'>{props.title}</p>
|
||||
{props.children}
|
||||
</div>
|
||||
{renderClose(props.onClose, props.closeTooltip)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(NotificationBox)
|
BIN
webapp/static/upgrade.png
Normal file
BIN
webapp/static/upgrade.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 49 KiB |
Loading…
x
Reference in New Issue
Block a user