1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-03-29 21:01:01 +02:00

Ported view limits to main ()

* 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:
Harshil Sharma 2022-06-29 18:05:24 +05:30 committed by GitHub
parent b40452e4ca
commit 2ac56dbfba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1950 additions and 15 deletions

@ -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"
}

@ -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>
`;

@ -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>
`;

@ -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;
}
}
}
}

@ -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)
})
})

@ -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}
},
)

@ -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>
`;

@ -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;
}
}
}

@ -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()
})
})

@ -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

Binary file not shown.

After

(image error) Size: 49 KiB