mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-18 18:18:23 -05:00
Message History Limits in Entry Edition (#33831)
* Support for Entry license with limits + updates to Edition & License screen * Refactor message history limit to use entry sku limits * Fixed missing update on license change * Fix typo in limit types * Revert unnecessary thread change * Revert merge issue * Cleanup * Fix CTAs of limit notifications * Linting * More linting * Linting and fix tests * More linting * Fix tests * PR feedback and fix tests * Fix tests * Fix test * Fix test * Linting * Simplified Limit panels * Linting * PR feedback * Revert back job time * Linting * linting * Fixed issue switching in RHS * PR Feedback --------- Co-authored-by: Nick Misasi <nick.misasi@mattermost.com> Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
parent
4169cb7b65
commit
3253b9ff6d
40 changed files with 1078 additions and 732 deletions
|
|
@ -17,10 +17,7 @@ func (api *API) InitLimits() {
|
|||
}
|
||||
|
||||
func getServerLimits(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !(c.IsSystemAdmin() && c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementUsers)) {
|
||||
c.SetPermissionError(model.PermissionSysconsoleReadUserManagementUsers)
|
||||
return
|
||||
}
|
||||
isAdmin := c.IsSystemAdmin() && c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementUsers)
|
||||
|
||||
serverLimits, err := c.App.GetServerLimits()
|
||||
if err != nil {
|
||||
|
|
@ -28,6 +25,18 @@ func getServerLimits(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Non-admin users only get message history limit information, no user count data
|
||||
if !isAdmin {
|
||||
limitedData := &model.ServerLimits{
|
||||
MaxUsersLimit: 0,
|
||||
MaxUsersHardLimit: 0,
|
||||
ActiveUserCount: 0,
|
||||
LastAccessiblePostTime: serverLimits.LastAccessiblePostTime,
|
||||
PostHistoryLimit: serverLimits.PostHistoryLimit,
|
||||
}
|
||||
serverLimits = limitedData
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(serverLimits); err != nil {
|
||||
c.Logger.Warn("Error writing server limits response", mlog.Err(err))
|
||||
}
|
||||
|
|
|
|||
186
server/channels/api4/limits_test.go
Normal file
186
server/channels/api4/limits_test.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
func TestGetServerLimits(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
t.Run("admin users can get full server limits", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
// Set up unlicensed server
|
||||
th.App.Srv().SetLicense(nil)
|
||||
|
||||
// Test with system admin
|
||||
serverLimits, resp, err := th.SystemAdminClient.GetServerLimits(context.Background())
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
|
||||
// Should have full access to all limits data
|
||||
require.Greater(t, serverLimits.ActiveUserCount, int64(0))
|
||||
require.Equal(t, int64(2500), serverLimits.MaxUsersLimit)
|
||||
require.Equal(t, int64(5000), serverLimits.MaxUsersHardLimit)
|
||||
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
|
||||
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
|
||||
})
|
||||
|
||||
t.Run("non-admin users get limited data with licensed server", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
// Set up licensed server with user limits
|
||||
userLimit := 100
|
||||
extraUsers := 10
|
||||
postHistoryLimit := int64(10000)
|
||||
license := model.NewTestLicense("")
|
||||
license.IsSeatCountEnforced = true
|
||||
license.Features.Users = &userLimit
|
||||
license.ExtraUsers = &extraUsers
|
||||
license.Limits = &model.LicenseLimits{
|
||||
PostHistory: postHistoryLimit,
|
||||
}
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
// Test with regular user
|
||||
serverLimits, resp, err := th.Client.GetServerLimits(context.Background())
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
|
||||
// Non-admin users should get zero for user count data (privacy)
|
||||
require.Equal(t, int64(0), serverLimits.ActiveUserCount)
|
||||
require.Equal(t, int64(0), serverLimits.MaxUsersLimit)
|
||||
require.Equal(t, int64(0), serverLimits.MaxUsersHardLimit)
|
||||
|
||||
// But should get message history limits (needed for UI)
|
||||
require.Equal(t, postHistoryLimit, serverLimits.PostHistoryLimit)
|
||||
})
|
||||
|
||||
t.Run("admin users get full limts", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
// Set up licensed server with post history limits
|
||||
userLimit := 100
|
||||
postHistoryLimit := int64(10000)
|
||||
license := model.NewTestLicense("")
|
||||
license.IsSeatCountEnforced = true
|
||||
license.Features.Users = &userLimit
|
||||
license.Limits = &model.LicenseLimits{
|
||||
PostHistory: postHistoryLimit,
|
||||
}
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
// Test with system admin
|
||||
serverLimits, resp, err := th.SystemAdminClient.GetServerLimits(context.Background())
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
|
||||
// Should have full access to all limits data
|
||||
require.Greater(t, serverLimits.ActiveUserCount, int64(0))
|
||||
require.Equal(t, int64(100), serverLimits.MaxUsersLimit)
|
||||
require.Equal(t, int64(100), serverLimits.MaxUsersHardLimit)
|
||||
|
||||
// Should have post history limits
|
||||
require.Equal(t, postHistoryLimit, serverLimits.PostHistoryLimit)
|
||||
// LastAccessiblePostTime may be 0 if no posts exist in test database, which is expected
|
||||
require.GreaterOrEqual(t, serverLimits.LastAccessiblePostTime, int64(0))
|
||||
})
|
||||
|
||||
t.Run("non-admin users get post history limits when configured", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
// Set up licensed server with post history limits
|
||||
userLimit := 100
|
||||
postHistoryLimit := int64(10000)
|
||||
license := model.NewTestLicense("")
|
||||
license.IsSeatCountEnforced = true
|
||||
license.Features.Users = &userLimit
|
||||
license.Limits = &model.LicenseLimits{
|
||||
PostHistory: postHistoryLimit,
|
||||
}
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
// Test with regular user
|
||||
serverLimits, resp, err := th.Client.GetServerLimits(context.Background())
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
|
||||
// Non-admin users should get zero for user count data (privacy)
|
||||
require.Equal(t, int64(0), serverLimits.ActiveUserCount)
|
||||
require.Equal(t, int64(0), serverLimits.MaxUsersLimit)
|
||||
require.Equal(t, int64(0), serverLimits.MaxUsersHardLimit)
|
||||
|
||||
// But should get post history limits (needed for UI)
|
||||
require.Equal(t, postHistoryLimit, serverLimits.PostHistoryLimit)
|
||||
|
||||
// LastAccessiblePostTime may be 0 if no posts exist in test database, which is expected
|
||||
require.GreaterOrEqual(t, serverLimits.LastAccessiblePostTime, int64(0))
|
||||
})
|
||||
|
||||
t.Run("zero post history limit shows no limits", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
// Set up licensed server with zero post history limit
|
||||
userLimit := 100
|
||||
postHistoryLimit := int64(0)
|
||||
license := model.NewTestLicense("")
|
||||
license.IsSeatCountEnforced = true
|
||||
license.Features.Users = &userLimit
|
||||
license.Limits = &model.LicenseLimits{
|
||||
PostHistory: postHistoryLimit,
|
||||
}
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
// Test with both admin and regular user
|
||||
clients := []*model.Client4{th.SystemAdminClient, th.Client}
|
||||
for i, client := range clients {
|
||||
serverLimits, resp, err := client.GetServerLimits(context.Background())
|
||||
require.NoError(t, err, "Failed for client %d", i)
|
||||
CheckOKStatus(t, resp)
|
||||
|
||||
// Should have no post history limits
|
||||
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
|
||||
|
||||
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("license with nil Limits shows no post history limits", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
// Set up licensed server with nil Limits
|
||||
userLimit := 100
|
||||
license := model.NewTestLicense("")
|
||||
license.IsSeatCountEnforced = true
|
||||
license.Features.Users = &userLimit
|
||||
license.Limits = nil // Explicitly set to nil
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
// Test with both admin and regular user
|
||||
clients := []*model.Client4{th.SystemAdminClient, th.Client}
|
||||
for i, client := range clients {
|
||||
serverLimits, resp, err := client.GetServerLimits(context.Background())
|
||||
require.NoError(t, err, "Failed for client %d", i)
|
||||
CheckOKStatus(t, resp)
|
||||
|
||||
// Should have no post history limits
|
||||
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
|
||||
|
||||
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -36,6 +36,17 @@ func (a *App) GetServerLimits() (*model.ServerLimits, *model.AppError) {
|
|||
limits.MaxUsersHardLimit = licenseUserLimit + int64(extraUsers)
|
||||
}
|
||||
|
||||
// Check if license has post history limits and get the calculated timestamp
|
||||
if license != nil && license.Limits != nil && license.Limits.PostHistory > 0 {
|
||||
limits.PostHistoryLimit = license.Limits.PostHistory
|
||||
// Get the calculated timestamp of the last accessible post
|
||||
lastAccessibleTime, appErr := a.GetLastAccessiblePostTime()
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
limits.LastAccessiblePostTime = lastAccessibleTime
|
||||
}
|
||||
|
||||
activeUserCount, appErr := a.Srv().Store().User().Count(model.UserCountOptions{})
|
||||
if appErr != nil {
|
||||
return nil, model.NewAppError("GetServerLimits", "app.limits.get_app_limits.user_count.store_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
|
||||
|
|
@ -44,6 +55,15 @@ func (a *App) GetServerLimits() (*model.ServerLimits, *model.AppError) {
|
|||
|
||||
return limits, nil
|
||||
}
|
||||
func (a *App) GetPostHistoryLimit() int64 {
|
||||
license := a.License()
|
||||
if license == nil || license.Limits == nil || license.Limits.PostHistory == 0 {
|
||||
// No limits applicable
|
||||
return 0
|
||||
}
|
||||
|
||||
return license.Limits.PostHistory
|
||||
}
|
||||
|
||||
func (a *App) isAtUserLimit() (bool, *model.AppError) {
|
||||
userLimits, appErr := a.GetServerLimits()
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
storemocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -549,3 +551,215 @@ func TestExtraUsersBehavior(t *testing.T) {
|
|||
require.Equal(t, int64(5000), serverLimits.MaxUsersHardLimit)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetServerLimitsWithPostHistory(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
t.Run("unlicensed server has no post history limits", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.Srv().SetLicense(nil)
|
||||
|
||||
serverLimits, appErr := th.App.GetServerLimits()
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Unlicensed servers should have no post history limits
|
||||
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
|
||||
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
|
||||
})
|
||||
|
||||
t.Run("licensed server without post history limits", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
license := model.NewTestLicense("")
|
||||
license.Limits = nil // No limits configured
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
serverLimits, appErr := th.App.GetServerLimits()
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Should have no post history limits when Limits is nil
|
||||
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
|
||||
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
|
||||
})
|
||||
|
||||
t.Run("licensed server with zero post history limit", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
license := model.NewTestLicense("")
|
||||
license.Limits = &model.LicenseLimits{
|
||||
PostHistory: 0, // Zero limit
|
||||
}
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
serverLimits, appErr := th.App.GetServerLimits()
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Should have no post history limits when PostHistory is 0
|
||||
require.Equal(t, int64(0), serverLimits.PostHistoryLimit)
|
||||
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
|
||||
})
|
||||
|
||||
t.Run("licensed server with positive post history limit and successful GetLastAccessiblePostTime", func(t *testing.T) {
|
||||
th := SetupWithStoreMock(t)
|
||||
defer th.TearDown()
|
||||
|
||||
// Mock user store for existing functionality
|
||||
mockStore := th.App.Srv().Store().(*storemocks.Store)
|
||||
mockUserStore := storemocks.UserStore{}
|
||||
mockUserStore.On("Count", mock.Anything).Return(int64(5), nil)
|
||||
mockStore.On("User").Return(&mockUserStore)
|
||||
|
||||
// Mock system store for GetLastAccessiblePostTime
|
||||
mockSystemStore := storemocks.SystemStore{}
|
||||
mockSystemStore.On("GetByName", model.SystemLastAccessiblePostTime).Return(&model.System{
|
||||
Name: model.SystemLastAccessiblePostTime,
|
||||
Value: "1234567890",
|
||||
}, nil)
|
||||
mockStore.On("System").Return(&mockSystemStore)
|
||||
|
||||
// Create Entry license with post history limit
|
||||
license := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
|
||||
license.Limits = &model.LicenseLimits{
|
||||
PostHistory: 1000,
|
||||
}
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
serverLimits, appErr := th.App.GetServerLimits()
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Should have proper post history limits set
|
||||
require.Equal(t, int64(1000), serverLimits.PostHistoryLimit)
|
||||
require.Equal(t, int64(1234567890), serverLimits.LastAccessiblePostTime)
|
||||
require.Equal(t, int64(5), serverLimits.ActiveUserCount)
|
||||
})
|
||||
|
||||
t.Run("licensed server with post history limit but GetLastAccessiblePostTime fails", func(t *testing.T) {
|
||||
th := SetupWithStoreMock(t)
|
||||
defer th.TearDown()
|
||||
|
||||
// Mock user store for existing functionality
|
||||
mockStore := th.App.Srv().Store().(*storemocks.Store)
|
||||
mockUserStore := storemocks.UserStore{}
|
||||
mockUserStore.On("Count", mock.Anything).Return(int64(5), nil)
|
||||
mockStore.On("User").Return(&mockUserStore)
|
||||
|
||||
// Mock system store to return error
|
||||
mockSystemStore := storemocks.SystemStore{}
|
||||
mockSystemStore.On("GetByName", model.SystemLastAccessiblePostTime).Return(nil, errors.New("database error"))
|
||||
mockStore.On("System").Return(&mockSystemStore)
|
||||
|
||||
// Create Entry license with post history limit
|
||||
license := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
|
||||
license.Limits = &model.LicenseLimits{
|
||||
PostHistory: 1000,
|
||||
}
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
_, appErr := th.App.GetServerLimits()
|
||||
require.NotNil(t, appErr)
|
||||
require.Contains(t, appErr.Message, "Unable to find the system variable")
|
||||
})
|
||||
|
||||
t.Run("licensed server with post history limit but no system value found", func(t *testing.T) {
|
||||
th := SetupWithStoreMock(t)
|
||||
defer th.TearDown()
|
||||
|
||||
// Mock user store for existing functionality
|
||||
mockStore := th.App.Srv().Store().(*storemocks.Store)
|
||||
mockUserStore := storemocks.UserStore{}
|
||||
mockUserStore.On("Count", mock.Anything).Return(int64(5), nil)
|
||||
mockStore.On("User").Return(&mockUserStore)
|
||||
|
||||
// Mock system store to return ErrNotFound (all posts accessible)
|
||||
mockSystemStore := storemocks.SystemStore{}
|
||||
mockSystemStore.On("GetByName", model.SystemLastAccessiblePostTime).Return(nil, store.NewErrNotFound("", ""))
|
||||
mockStore.On("System").Return(&mockSystemStore)
|
||||
|
||||
// Create Entry license with post history limit
|
||||
license := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
|
||||
license.Limits = &model.LicenseLimits{
|
||||
PostHistory: 1000,
|
||||
}
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
serverLimits, appErr := th.App.GetServerLimits()
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Should have post history limit set but LastAccessiblePostTime should be 0 (all posts accessible)
|
||||
require.Equal(t, int64(1000), serverLimits.PostHistoryLimit)
|
||||
require.Equal(t, int64(0), serverLimits.LastAccessiblePostTime)
|
||||
require.Equal(t, int64(5), serverLimits.ActiveUserCount)
|
||||
})
|
||||
}
|
||||
func TestGetPostHistoryLimit(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
t.Run("unlicensed server returns zero limit", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.Srv().SetLicense(nil)
|
||||
|
||||
limit := th.App.GetPostHistoryLimit()
|
||||
require.Equal(t, int64(0), limit)
|
||||
})
|
||||
|
||||
t.Run("licensed server with no Limits returns zero", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
license := model.NewTestLicense("")
|
||||
license.Limits = nil // No limits configured
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
limit := th.App.GetPostHistoryLimit()
|
||||
require.Equal(t, int64(0), limit)
|
||||
})
|
||||
|
||||
t.Run("licensed server with zero PostHistory returns zero", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
license := model.NewTestLicense("")
|
||||
license.Limits = &model.LicenseLimits{
|
||||
PostHistory: 0,
|
||||
}
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
limit := th.App.GetPostHistoryLimit()
|
||||
require.Equal(t, int64(0), limit)
|
||||
})
|
||||
|
||||
t.Run("licensed server with positive PostHistory returns exact value", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
license := model.NewTestLicense("")
|
||||
license.Limits = &model.LicenseLimits{
|
||||
PostHistory: 1500,
|
||||
}
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
limit := th.App.GetPostHistoryLimit()
|
||||
require.Equal(t, int64(1500), limit)
|
||||
})
|
||||
|
||||
t.Run("Entry license with PostHistory returns exact value", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
license := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
|
||||
|
||||
license.Limits = &model.LicenseLimits{
|
||||
PostHistory: 2000,
|
||||
}
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
limit := th.App.GetPostHistoryLimit()
|
||||
require.Equal(t, int64(2000), limit)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,13 @@ func (ps *PlatformService) LoadLicense() {
|
|||
if nErr != nil {
|
||||
if ps.Config().FeatureFlags.EnableMattermostEntry && model.BuildEnterpriseReady == "true" {
|
||||
ps.logger.Info("Mattermost Entry is enabled. Unlocking enterprise features.")
|
||||
|
||||
if ps.LicenseManager() == nil {
|
||||
ps.logger.Warn("License manager not available, setting license to nil.")
|
||||
ps.SetLicense(nil)
|
||||
return
|
||||
}
|
||||
|
||||
ps.SetLicense(ps.LicenseManager().NewMattermostEntryLicense(ps.telemetryId))
|
||||
} else {
|
||||
ps.logger.Warn("License key from https://mattermost.com required to unlock enterprise features.", mlog.Err(nErr))
|
||||
|
|
|
|||
|
|
@ -1574,10 +1574,12 @@ func (a *App) convertUserNameToUserIds(rctx request.CTX, usernames []string) []s
|
|||
return usernames
|
||||
}
|
||||
|
||||
// GetLastAccessiblePostTime returns CreateAt time(from cache) of the last accessible post as per the cloud limit
|
||||
// GetLastAccessiblePostTime returns CreateAt time(from cache) of the last accessible post as per the license limit
|
||||
func (a *App) GetLastAccessiblePostTime() (int64, *model.AppError) {
|
||||
// Only calculate the last accessible post time when there are actual post history limits
|
||||
license := a.Srv().License()
|
||||
if license == nil || !license.IsCloud() {
|
||||
|
||||
if license == nil || license.Limits == nil || license.Limits.PostHistory == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
|
|
@ -1601,13 +1603,10 @@ func (a *App) GetLastAccessiblePostTime() (int64, *model.AppError) {
|
|||
return lastAccessiblePostTime, nil
|
||||
}
|
||||
|
||||
// ComputeLastAccessiblePostTime updates cache with CreateAt time of the last accessible post as per the cloud plan's limit.
|
||||
// ComputeLastAccessiblePostTime updates cache with CreateAt time of the last accessible post as per the license limit.
|
||||
// Use GetLastAccessiblePostTime() to access the result.
|
||||
func (a *App) ComputeLastAccessiblePostTime() error {
|
||||
limit, appErr := a.getCloudMessagesHistoryLimit()
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
limit := a.GetPostHistoryLimit()
|
||||
|
||||
if limit == 0 {
|
||||
// All posts are accessible - we must check if a previous value was set so we can clear it
|
||||
|
|
@ -1628,7 +1627,7 @@ func (a *App) ComputeLastAccessiblePostTime() error {
|
|||
return model.NewAppError("ComputeLastAccessiblePostTime", "app.system.permanent_delete_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
}
|
||||
// Cloud limit is not applicable
|
||||
// Message history limit is not applicable
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1652,25 +1651,6 @@ func (a *App) ComputeLastAccessiblePostTime() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (a *App) getCloudMessagesHistoryLimit() (int64, *model.AppError) {
|
||||
license := a.Srv().License()
|
||||
if license == nil || !license.IsCloud() {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
limits, err := a.Cloud().GetCloudLimits("")
|
||||
if err != nil {
|
||||
return 0, model.NewAppError("getCloudMessagesHistoryLimit", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
if limits == nil || limits.Messages == nil || limits.Messages.History == nil {
|
||||
// Cloud limit is not applicable
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return int64(*limits.Messages.History), nil
|
||||
}
|
||||
|
||||
func (a *App) SearchPostsInTeam(teamID string, paramsList []*model.SearchParams) (*model.PostList, *model.AppError) {
|
||||
if !*a.Config().ServiceSettings.EnablePostSearch {
|
||||
return nil, model.NewAppError("SearchPostsInTeam", "store.sql_post.search.disabled", nil, fmt.Sprintf("teamId=%v", teamID), http.StatusNotImplemented)
|
||||
|
|
|
|||
|
|
@ -196,7 +196,12 @@ func TestGetTimeSortedPostAccessibleBounds(t *testing.T) {
|
|||
func TestFilterInaccessiblePosts(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t)
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
|
||||
|
||||
// Set up license with PostHistory limits to enable post filtering
|
||||
cloudLicenseWithLimits := model.NewTestLicense("cloud")
|
||||
cloudLicenseWithLimits.Limits = &model.LicenseLimits{PostHistory: 100}
|
||||
th.App.Srv().SetLicense(cloudLicenseWithLimits)
|
||||
|
||||
err := th.App.Srv().Store().System().Save(&model.System{
|
||||
Name: model.SystemLastAccessiblePostTime,
|
||||
Value: "2",
|
||||
|
|
@ -326,7 +331,11 @@ func TestFilterInaccessiblePosts(t *testing.T) {
|
|||
func TestGetFilteredAccessiblePosts(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t)
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
|
||||
|
||||
entryLicenseWithLimits := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
|
||||
entryLicenseWithLimits.Limits = &model.LicenseLimits{PostHistory: 100}
|
||||
th.App.Srv().SetLicense(entryLicenseWithLimits)
|
||||
|
||||
err := th.App.Srv().Store().System().Save(&model.System{
|
||||
Name: model.SystemLastAccessiblePostTime,
|
||||
Value: "2",
|
||||
|
|
@ -369,7 +378,12 @@ func TestGetFilteredAccessiblePosts(t *testing.T) {
|
|||
func TestIsInaccessiblePost(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t)
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
|
||||
|
||||
// Set up license with PostHistory limits to enable post filtering
|
||||
entryLicenseWithLimits := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
|
||||
entryLicenseWithLimits.Limits = &model.LicenseLimits{PostHistory: 100}
|
||||
th.App.Srv().SetLicense(entryLicenseWithLimits)
|
||||
|
||||
err := th.App.Srv().Store().System().Save(&model.System{
|
||||
Name: model.SystemLastAccessiblePostTime,
|
||||
Value: "2",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import (
|
|||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
storemocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/testlib"
|
||||
eMocks "github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/services/imageproxy"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/services/searchengine/mocks"
|
||||
)
|
||||
|
|
@ -2581,7 +2580,12 @@ func TestCountMentionsFromPost(t *testing.T) {
|
|||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
|
||||
// Create an Entry license with post history limits
|
||||
license := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
|
||||
license.Limits = &model.LicenseLimits{
|
||||
PostHistory: 10000, // Set some post history limit to enable filtering
|
||||
}
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
user1 := th.BasicUser
|
||||
user2 := th.BasicUser2
|
||||
|
|
@ -3484,14 +3488,39 @@ func TestGetLastAccessiblePostTime(t *testing.T) {
|
|||
th := SetupWithStoreMock(t)
|
||||
defer th.TearDown()
|
||||
|
||||
// Setup store mocks needed for GetServerLimits
|
||||
mockStore := th.App.Srv().Store().(*storemocks.Store)
|
||||
mockUserStore := storemocks.UserStore{}
|
||||
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
|
||||
mockStore.On("User").Return(&mockUserStore)
|
||||
|
||||
// Test with no license - should return 0
|
||||
r, err := th.App.GetLastAccessiblePostTime()
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, int64(0), r)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
|
||||
// Test with Entry license but no limits configured - should return 0
|
||||
entryLicenseNoLimits := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
|
||||
entryLicenseNoLimits.Limits = nil // No limits configured
|
||||
th.App.Srv().SetLicense(entryLicenseNoLimits)
|
||||
r, err = th.App.GetLastAccessiblePostTime()
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, int64(0), r, "Entry license with no limits should return 0")
|
||||
|
||||
mockStore := th.App.Srv().Store().(*storemocks.Store)
|
||||
// Test with Entry license with zero post history limit - should return 0
|
||||
entryLicenseZeroLimit := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
|
||||
entryLicenseZeroLimit.Limits = &model.LicenseLimits{PostHistory: 0} // Zero limit
|
||||
th.App.Srv().SetLicense(entryLicenseZeroLimit)
|
||||
r, err = th.App.GetLastAccessiblePostTime()
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, int64(0), r, "Entry license with zero post history limit should return 0")
|
||||
|
||||
// Test with Entry license that has post history limits
|
||||
entryLicenseWithLimits := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
|
||||
entryLicenseWithLimits.Limits = &model.LicenseLimits{PostHistory: 1000} // Actual limit
|
||||
th.App.Srv().SetLicense(entryLicenseWithLimits)
|
||||
|
||||
// Test case 1: No system value found (ErrNotFound) - should return 0
|
||||
mockSystemStore := storemocks.SystemStore{}
|
||||
mockStore.On("System").Return(&mockSystemStore)
|
||||
mockSystemStore.On("GetByName", mock.Anything).Return(nil, store.NewErrNotFound("", ""))
|
||||
|
|
@ -3499,41 +3528,36 @@ func TestGetLastAccessiblePostTime(t *testing.T) {
|
|||
assert.Nil(t, err)
|
||||
assert.Equal(t, int64(0), r)
|
||||
|
||||
// Test case 2: Database error - should return error
|
||||
mockSystemStore = storemocks.SystemStore{}
|
||||
mockStore.On("System").Return(&mockSystemStore)
|
||||
mockSystemStore.On("GetByName", mock.Anything).Return(nil, errors.New("test"))
|
||||
mockSystemStore.On("GetByName", mock.Anything).Return(nil, errors.New("database error"))
|
||||
_, err = th.App.GetLastAccessiblePostTime()
|
||||
assert.NotNil(t, err)
|
||||
|
||||
// Test case 3: Valid system value found - should return parsed timestamp
|
||||
mockSystemStore = storemocks.SystemStore{}
|
||||
mockStore.On("System").Return(&mockSystemStore)
|
||||
mockSystemStore.On("GetByName", mock.Anything).Return(&model.System{Name: model.SystemLastAccessiblePostTime, Value: "10"}, nil)
|
||||
mockSystemStore.On("GetByName", mock.Anything).Return(&model.System{Name: model.SystemLastAccessiblePostTime, Value: "1234567890"}, nil)
|
||||
r, err = th.App.GetLastAccessiblePostTime()
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, int64(10), r)
|
||||
assert.Equal(t, int64(1234567890), r)
|
||||
}
|
||||
|
||||
func TestComputeLastAccessiblePostTime(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
t.Run("Updates the time, if cloud limit is applicable", func(t *testing.T) {
|
||||
t.Run("Updates the time, if Entry license limit is applicable", func(t *testing.T) {
|
||||
th := SetupWithStoreMock(t)
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
|
||||
|
||||
cloud := &eMocks.CloudInterface{}
|
||||
th.App.Srv().Cloud = cloud
|
||||
|
||||
// cloud-starter, limit is applicable
|
||||
cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{
|
||||
Messages: &model.MessagesLimits{
|
||||
History: model.NewPointer(1),
|
||||
},
|
||||
}, nil)
|
||||
// Set Entry license with post history limit of 100 messages
|
||||
entryLicensePostsLimit := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
|
||||
entryLicensePostsLimit.Limits = &model.LicenseLimits{PostHistory: 100}
|
||||
th.App.Srv().SetLicense(entryLicensePostsLimit)
|
||||
|
||||
mockStore := th.App.Srv().Store().(*storemocks.Store)
|
||||
mockPostStore := storemocks.PostStore{}
|
||||
mockPostStore.On("GetNthRecentPostTime", mock.Anything).Return(int64(1), nil)
|
||||
mockPostStore.On("GetNthRecentPostTime", int64(100)).Return(int64(1234567890), nil)
|
||||
mockSystemStore := storemocks.SystemStore{}
|
||||
mockSystemStore.On("SaveOrUpdate", mock.Anything).Return(nil)
|
||||
mockStore.On("Post").Return(&mockPostStore)
|
||||
|
|
@ -3542,32 +3566,35 @@ func TestComputeLastAccessiblePostTime(t *testing.T) {
|
|||
err := th.App.ComputeLastAccessiblePostTime()
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockSystemStore.AssertCalled(t, "SaveOrUpdate", mock.Anything)
|
||||
// Verify that the system value was saved with the calculated timestamp
|
||||
mockSystemStore.AssertCalled(t, "SaveOrUpdate", &model.System{
|
||||
Name: model.SystemLastAccessiblePostTime,
|
||||
Value: "1234567890",
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Remove the time if cloud limit is NOT applicable", func(t *testing.T) {
|
||||
t.Run("Remove the time if license limit is NOT applicable", func(t *testing.T) {
|
||||
th := SetupWithStoreMock(t)
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
|
||||
|
||||
cloud := &eMocks.CloudInterface{}
|
||||
th.App.Srv().Cloud = cloud
|
||||
|
||||
// enterprise, limit is NOT applicable
|
||||
cloud.Mock.On("GetCloudLimits", mock.Anything).Return(nil, nil)
|
||||
// Set license without post history limits (using test license without limits)
|
||||
license := model.NewTestLicense()
|
||||
license.Limits = nil // No limits
|
||||
th.App.Srv().SetLicense(license)
|
||||
|
||||
mockStore := th.App.Srv().Store().(*storemocks.Store)
|
||||
mockSystemStore := storemocks.SystemStore{}
|
||||
mockSystemStore.On("GetByName", mock.Anything).Return(&model.System{Name: model.SystemLastAccessiblePostTime, Value: "10"}, nil)
|
||||
mockSystemStore.On("PermanentDeleteByName", mock.Anything).Return(nil, nil)
|
||||
mockSystemStore.On("GetByName", model.SystemLastAccessiblePostTime).Return(&model.System{Name: model.SystemLastAccessiblePostTime, Value: "1234567890"}, nil)
|
||||
mockSystemStore.On("PermanentDeleteByName", model.SystemLastAccessiblePostTime).Return(nil, nil)
|
||||
mockStore.On("System").Return(&mockSystemStore)
|
||||
|
||||
err := th.App.ComputeLastAccessiblePostTime()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify that SaveOrUpdate was not called (no new timestamp calculated)
|
||||
mockSystemStore.AssertNotCalled(t, "SaveOrUpdate", mock.Anything)
|
||||
mockSystemStore.AssertCalled(t, "PermanentDeleteByName", mock.Anything)
|
||||
// Verify that the previous value was deleted
|
||||
mockSystemStore.AssertCalled(t, "PermanentDeleteByName", model.SystemLastAccessiblePostTime)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ const schedFreq = 30 * time.Minute
|
|||
|
||||
func MakeScheduler(jobServer *jobs.JobServer, license *model.License) *jobs.PeriodicScheduler {
|
||||
isEnabled := func(cfg *model.Config) bool {
|
||||
enabled := license != nil && *license.Features.Cloud
|
||||
// Enable for any license with post history limits (i.e. Entry SKU)
|
||||
enabled := license != nil && license.Limits != nil && license.Limits.PostHistory > 0
|
||||
mlog.Debug("Scheduler: isEnabled: "+strconv.FormatBool(enabled), mlog.String("scheduler", model.JobTypeLastAccessiblePost))
|
||||
return enabled
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ func MakeWorker(jobServer *jobs.JobServer, license *model.License, app AppIface)
|
|||
const workerName = "LastAccessiblePost"
|
||||
|
||||
isEnabled := func(_ *model.Config) bool {
|
||||
return license != nil && license.Features != nil && *license.Features.Cloud
|
||||
// Enable for any license with post history limits (i.e. Entry SKU)
|
||||
return license != nil && license.Limits != nil && license.Limits.PostHistory > 0
|
||||
}
|
||||
execute := func(logger mlog.LoggerIFace, job *model.Job) error {
|
||||
defer jobServer.HandleJobPanic(logger, job)
|
||||
|
|
|
|||
|
|
@ -639,7 +639,7 @@ func (c *Client4) accessControlPolicyRoute(policyID string) string {
|
|||
}
|
||||
|
||||
func (c *Client4) GetServerLimits(ctx context.Context) (*ServerLimits, *Response, error) {
|
||||
r, err := c.DoAPIGet(ctx, c.limitsRoute()+"/users", "")
|
||||
r, err := c.DoAPIGet(ctx, c.limitsRoute()+"/server", "")
|
||||
if err != nil {
|
||||
return nil, BuildResponse(r), err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ func (f *FeatureFlags) SetDefaults() {
|
|||
f.AttributeBasedAccessControl = true
|
||||
f.ContentFlagging = false
|
||||
f.InteractiveDialogAppsForm = true
|
||||
f.EnableMattermostEntry = false
|
||||
f.EnableMattermostEntry = true
|
||||
// FEATURE_FLAG_REMOVAL: ChannelAdminManageABACRules - Remove this default when feature is GA
|
||||
f.ChannelAdminManageABACRules = false // Default to false for safety
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,4 +7,7 @@ type ServerLimits struct {
|
|||
MaxUsersLimit int64 `json:"maxUsersLimit"` // soft limit for max number of users.
|
||||
MaxUsersHardLimit int64 `json:"maxUsersHardLimit"` // hard limit for max number of active users.
|
||||
ActiveUserCount int64 `json:"activeUserCount"` // actual number of active users on server. Active = non deleted
|
||||
// Post history limit fields
|
||||
PostHistoryLimit int64 `json:"postHistoryLimit"` // The actual message history limit value (0 if no limits)
|
||||
LastAccessiblePostTime int64 `json:"lastAccessiblePostTime"` // Timestamp of the last accessible post (0 if no limits reached)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,7 +151,9 @@ describe('executeCommand', () => {
|
|||
{type: 'UPDATE_RHS_SEARCH_RESULTS_TERMS', terms: ''},
|
||||
{type: 'UPDATE_RHS_SEARCH_RESULTS_TYPE', searchType: ''},
|
||||
{type: 'SEARCH_POSTS_REQUEST', isGettingMore: false},
|
||||
{data: {firstInaccessiblePostTime: 0, searchType: 'posts'}, type: 'RECEIVED_SEARCH_TRUNCATION_INFO'},
|
||||
{type: 'SEARCH_FILES_REQUEST', isGettingMore: false},
|
||||
{data: {firstInaccessiblePostTime: 0, searchType: 'files'}, type: 'RECEIVED_SEARCH_TRUNCATION_INFO'},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -428,7 +428,9 @@ describe('Actions.Posts', () => {
|
|||
{terms: '', type: 'UPDATE_RHS_SEARCH_RESULTS_TERMS'},
|
||||
{searchType: '', type: 'UPDATE_RHS_SEARCH_RESULTS_TYPE'},
|
||||
{isGettingMore: false, type: 'SEARCH_POSTS_REQUEST'},
|
||||
{data: {firstInaccessiblePostTime: 0, searchType: 'posts'}, type: 'RECEIVED_SEARCH_TRUNCATION_INFO'},
|
||||
{isGettingMore: false, type: 'SEARCH_FILES_REQUEST'},
|
||||
{data: {firstInaccessiblePostTime: 0, searchType: 'files'}, type: 'RECEIVED_SEARCH_TRUNCATION_INFO'},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {getCloudSubscription} from 'mattermost-redux/actions/cloud';
|
|||
import {clearErrors, logError} from 'mattermost-redux/actions/errors';
|
||||
import {setServerVersion, getClientConfig, getCustomProfileAttributeFields} from 'mattermost-redux/actions/general';
|
||||
import {getGroup as fetchGroup} from 'mattermost-redux/actions/groups';
|
||||
import {getServerLimits} from 'mattermost-redux/actions/limits';
|
||||
import {
|
||||
getCustomEmojiForReaction,
|
||||
getPosts,
|
||||
|
|
@ -1407,6 +1408,9 @@ function handleConfigChanged(msg) {
|
|||
|
||||
function handleLicenseChanged(msg) {
|
||||
store.dispatch({type: GeneralTypes.CLIENT_LICENSE_RECEIVED, data: msg.data.license});
|
||||
|
||||
// Refresh server limits when license changes since limits may have changed
|
||||
dispatch(getServerLimits());
|
||||
}
|
||||
|
||||
function handlePluginStatusesChangedEvent(msg) {
|
||||
|
|
|
|||
|
|
@ -6,12 +6,20 @@
|
|||
padding: 16px;
|
||||
border: 1px rgba(var(--center-channel-color-rgb), 0.08) solid;
|
||||
border-radius: 4px;
|
||||
margin: 0 24px 13px;
|
||||
margin: 0 16px 16px;
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
gap: 12px;
|
||||
|
||||
&__left {
|
||||
align-self: flex-start;
|
||||
padding: 0 12px;
|
||||
padding: 0;
|
||||
}
|
||||
&__right {
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
|
|
@ -19,7 +27,7 @@
|
|||
}
|
||||
|
||||
&__title {
|
||||
padding-bottom: 8px;
|
||||
padding-bottom: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import React from 'react';
|
|||
|
||||
import {emptyLimits} from 'tests/constants/cloud';
|
||||
import {emptyTeams} from 'tests/constants/teams';
|
||||
import {adminUsersState, endUsersState} from 'tests/constants/users';
|
||||
import {screen, renderWithContext} from 'tests/react_testing_utils';
|
||||
import {makeEmptyUsage} from 'utils/limits_test';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
|
@ -24,7 +23,6 @@ jest.mock('mattermost-redux/actions/cloud', () => {
|
|||
const initialState = {
|
||||
entities: {
|
||||
usage: makeEmptyUsage(),
|
||||
users: adminUsersState(),
|
||||
cloud: {
|
||||
limits: {...emptyLimits(), limitsLoaded: false},
|
||||
},
|
||||
|
|
@ -32,6 +30,12 @@ const initialState = {
|
|||
license: TestHelper.getCloudLicenseMock(),
|
||||
},
|
||||
teams: emptyTeams(),
|
||||
limits: {
|
||||
serverLimits: {
|
||||
activeUserCount: 0,
|
||||
maxUsersLimit: 0,
|
||||
},
|
||||
},
|
||||
posts: {
|
||||
postsInChannel: {
|
||||
channelId: [
|
||||
|
|
@ -54,110 +58,34 @@ const exceededLimitsState = {
|
|||
...initialState,
|
||||
entities: {
|
||||
...initialState.entities,
|
||||
cloud: {
|
||||
...initialState.entities.cloud,
|
||||
limits: {
|
||||
...initialState.entities.cloud.limits,
|
||||
limitsLoaded: true,
|
||||
limits: {
|
||||
messages: {
|
||||
history: 2,
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
serverLimits: {
|
||||
activeUserCount: 0,
|
||||
maxUsersLimit: 0,
|
||||
postHistoryLimit: 2,
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
...initialState.entities.usage,
|
||||
messages: {
|
||||
...initialState.entities.usage.messages,
|
||||
history: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const exceededLimitsStateNoAccessiblePosts = {
|
||||
...exceededLimitsState,
|
||||
entities: {
|
||||
...exceededLimitsState.entities,
|
||||
posts: {
|
||||
postsInChannel: {
|
||||
channelId: [
|
||||
],
|
||||
},
|
||||
posts: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const endUserLimitExceeded = {
|
||||
...exceededLimitsState,
|
||||
entities: {
|
||||
...exceededLimitsState.entities,
|
||||
users: endUsersState(),
|
||||
},
|
||||
};
|
||||
|
||||
describe('CenterMessageLock', () => {
|
||||
it('returns null if limits not loaded', () => {
|
||||
it('shows message when limits are exceeded', () => {
|
||||
renderWithContext(
|
||||
<CenterMessageLock channelId={'channelId'}/>,
|
||||
initialState,
|
||||
);
|
||||
expect(screen.queryByText('Notify Admin')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Upgrade now')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Admins have a call to upgrade', () => {
|
||||
renderWithContext(
|
||||
<CenterMessageLock channelId={'channelId'}/>,
|
||||
<CenterMessageLock/>,
|
||||
exceededLimitsState,
|
||||
);
|
||||
screen.getByText('Upgrade now');
|
||||
screen.getByText('Limited history is displayed', {exact: false});
|
||||
screen.getByText('Full access to message history is included in');
|
||||
});
|
||||
|
||||
it('End users have a call to notify admin', () => {
|
||||
it('pricing link is clickable', () => {
|
||||
renderWithContext(
|
||||
<CenterMessageLock channelId={'channelId'}/>,
|
||||
endUserLimitExceeded,
|
||||
);
|
||||
screen.getByText('Notify Admin');
|
||||
});
|
||||
|
||||
it('Filtered messages over one year old display year', () => {
|
||||
renderWithContext(
|
||||
<CenterMessageLock channelId={'channelId'}/>,
|
||||
<CenterMessageLock/>,
|
||||
exceededLimitsState,
|
||||
);
|
||||
screen.getByText('January 1, 1970', {exact: false});
|
||||
});
|
||||
|
||||
it('New filtered messages do not show year', () => {
|
||||
const state = JSON.parse(JSON.stringify(exceededLimitsState));
|
||||
const now = new Date();
|
||||
const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const expectedDate = firstOfMonth.toLocaleString('en', {month: 'long', day: 'numeric'});
|
||||
|
||||
state.entities.posts.posts.c.create_at = Date.parse(firstOfMonth.toUTCString());
|
||||
renderWithContext(
|
||||
<CenterMessageLock channelId={'channelId'}/>,
|
||||
state,
|
||||
);
|
||||
screen.getByText(expectedDate, {exact: false});
|
||||
});
|
||||
|
||||
it('when there are no messages, uses day after day of most recently archived post', () => {
|
||||
const now = Date.now();
|
||||
const secondOfMonth = new Date(now + (1000 * 60 * 60 * 24));
|
||||
const expectedDate = secondOfMonth.toLocaleString('en', {month: 'long', day: 'numeric'});
|
||||
|
||||
renderWithContext(
|
||||
<CenterMessageLock
|
||||
channelId={'channelId'}
|
||||
firstInaccessiblePostTime={now}
|
||||
/>,
|
||||
exceededLimitsStateNoAccessiblePosts,
|
||||
);
|
||||
screen.getByText(expectedDate, {exact: false});
|
||||
const pricingLink = screen.getByText('paid plans');
|
||||
expect(pricingLink.tagName).toBe('A');
|
||||
expect(pricingLink).toHaveAttribute('href', '#');
|
||||
expect(pricingLink).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,165 +3,56 @@
|
|||
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import type {FormatDateOptions} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {EyeOffOutlineIcon} from '@mattermost/compass-icons/components';
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
import {getOldestPostTimeInChannel} from 'mattermost-redux/selectors/entities/posts';
|
||||
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
|
||||
import {isAdmin} from 'mattermost-redux/utils/user_utils';
|
||||
|
||||
import useGetLimits from 'components/common/hooks/useGetLimits';
|
||||
import {NotifyStatus} from 'components/common/hooks/useGetNotifyAdmin';
|
||||
import useGetServerLimits from 'components/common/hooks/useGetServerLimits';
|
||||
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
|
||||
import {useNotifyAdmin} from 'components/notify_admin_cta/notify_admin_cta';
|
||||
|
||||
import {LicenseSkus, MattermostFeatures} from 'utils/constants';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
|
||||
const ONE_YEAR_MS = ONE_DAY_MS * 365;
|
||||
|
||||
interface Props {
|
||||
channelId?: string;
|
||||
firstInaccessiblePostTime?: number;
|
||||
}
|
||||
|
||||
// returns the same time on the next day.
|
||||
function getNextDay(timestamp?: number): number {
|
||||
if (timestamp === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return timestamp + ONE_DAY_MS;
|
||||
}
|
||||
|
||||
export default function CenterMessageLock(props: Props) {
|
||||
export default function CenterMessageLock() {
|
||||
const intl = useIntl();
|
||||
|
||||
const {openPricingModal, isAirGapped} = useOpenPricingModal();
|
||||
const isAdminUser = isAdmin(useSelector(getCurrentUser).roles);
|
||||
const [cloudLimits, limitsLoaded] = useGetLimits();
|
||||
const currentTeam = useSelector(getCurrentTeam);
|
||||
const {openPricingModal} = useOpenPricingModal();
|
||||
|
||||
// firstInaccessiblePostTime is the most recently inaccessible post's created at date.
|
||||
// It is used as a backup for when there are no available posts in the channel;
|
||||
// The message then shows that the user can retrieve messages prior to the day
|
||||
// **after** the most recent day with inaccessible posts.
|
||||
const oldestPostTime = useSelector((state: GlobalState) => getOldestPostTimeInChannel(state, props.channelId || '')) || getNextDay(props.firstInaccessiblePostTime);
|
||||
const [notifyAdminBtnText, notifyAdmin, notifyRequestStatus] = useNotifyAdmin({
|
||||
ctaText: intl.formatMessage({
|
||||
id: 'workspace_limits.message_history.locked.cta.end_user',
|
||||
defaultMessage: 'Notify Admin',
|
||||
}),
|
||||
}, {
|
||||
required_feature: MattermostFeatures.UNLIMITED_MESSAGES,
|
||||
required_plan: LicenseSkus.Professional,
|
||||
trial_notification: false,
|
||||
});
|
||||
const [limitsLoaded] = useGetServerLimits();
|
||||
|
||||
if (!limitsLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateFormat: FormatDateOptions = {month: 'long', day: 'numeric'};
|
||||
if (Date.now() - oldestPostTime >= ONE_YEAR_MS) {
|
||||
dateFormat.year = 'numeric';
|
||||
}
|
||||
const titleValues = {
|
||||
date: intl.formatDate(oldestPostTime, dateFormat),
|
||||
team: currentTeam?.display_name,
|
||||
};
|
||||
const title = intl.formatMessage({
|
||||
id: 'workspace_limits.message_history.locked.title.admin',
|
||||
defaultMessage: 'Limited history is displayed',
|
||||
});
|
||||
|
||||
const limit = intl.formatNumber(cloudLimits?.messages?.history || 0);
|
||||
|
||||
let title = intl.formatMessage(
|
||||
const description = intl.formatMessage(
|
||||
{
|
||||
id: 'workspace_limits.message_history.locked.title.end_user',
|
||||
defaultMessage: 'Notify your admin to unlock messages prior to {date} in {team}',
|
||||
},
|
||||
titleValues,
|
||||
);
|
||||
let description: React.ReactNode = intl.formatMessage(
|
||||
{
|
||||
id: 'workspace_limits.message_history.locked.description.end_user',
|
||||
defaultMessage: 'Some older messages may not be shown because your workspace has over {limit} messages. Select Notify Admin to send an automatic request to your System Admins to upgrade.',
|
||||
id: 'workspace_limits.message_history.locked.description.admin',
|
||||
defaultMessage: 'Full access to message history is included in <a>paid plans</a>',
|
||||
},
|
||||
{
|
||||
limit,
|
||||
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
|
||||
<a
|
||||
href='#'
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
openPricingModal();
|
||||
}}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
let cta: React.ReactNode = (
|
||||
<button
|
||||
className='btn btn-primary'
|
||||
onClick={(e) => notifyAdmin(e)}
|
||||
disabled={notifyRequestStatus === NotifyStatus.AlreadyComplete}
|
||||
>
|
||||
{notifyAdminBtnText}
|
||||
</button>);
|
||||
|
||||
if (isAdminUser) {
|
||||
title = intl.formatMessage({
|
||||
id: 'workspace_limits.message_history.locked.title.admin',
|
||||
defaultMessage: 'Unlock messages prior to {date} in {team}',
|
||||
}, titleValues);
|
||||
|
||||
if (isAirGapped) {
|
||||
description = intl.formatMessage(
|
||||
{
|
||||
id: 'workspace_limits.message_history.locked.description.admin.airgapped',
|
||||
defaultMessage: 'To view and search all of the messages in your workspace\'s history, rather than just the most recent {limit} messages, upgrade to one of our paid plans.',
|
||||
},
|
||||
{
|
||||
limit,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
description = intl.formatMessage(
|
||||
{
|
||||
id: 'workspace_limits.message_history.locked.description.admin',
|
||||
defaultMessage: 'To view and search all of the messages in your workspace\'s history, rather than just the most recent {limit} messages, upgrade to one of our paid plans. <a>Review our plan options and pricing.</a>',
|
||||
},
|
||||
{
|
||||
limit,
|
||||
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
|
||||
<a
|
||||
href='#'
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
openPricingModal();
|
||||
}}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
cta = isAirGapped ? null : (
|
||||
<button
|
||||
className='btn is-admin'
|
||||
onClick={() => openPricingModal()}
|
||||
>
|
||||
{
|
||||
intl.formatMessage({
|
||||
id: 'workspace_limits.message_history.locked.cta.admin',
|
||||
defaultMessage: 'Upgrade now',
|
||||
})
|
||||
}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (<div className='CenterMessageLock'>
|
||||
<div className='CenterMessageLock__left'>
|
||||
<EyeOffOutlineIcon color={'rgba(var(--center-channel-color-rgb), 0.75)'}/>
|
||||
<EyeOffOutlineIcon
|
||||
size={16}
|
||||
color={'rgba(var(--center-channel-color-rgb), 0.75)'}
|
||||
/>
|
||||
</div>
|
||||
<div className='CenterMessageLock__right'>
|
||||
<div className='CenterMessageLock__title'>
|
||||
|
|
@ -170,9 +61,6 @@ export default function CenterMessageLock(props: Props) {
|
|||
<div className='CenterMessageLock__description'>
|
||||
{description}
|
||||
</div>
|
||||
<div className='CenterMessageLock__cta'>
|
||||
{cta}
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import type {ServerLimits} from '@mattermost/types/limits';
|
||||
|
||||
import {getServerLimits as getServerLimitsAction} from 'mattermost-redux/actions/limits';
|
||||
import {getServerLimits as getServerLimitsSelector} from 'mattermost-redux/selectors/entities/limits';
|
||||
|
||||
import {useIsLoggedIn} from 'components/global_header/hooks';
|
||||
|
||||
export default function useGetServerLimits(): [ServerLimits, boolean] {
|
||||
const isLoggedIn = useIsLoggedIn();
|
||||
const serverLimits = useSelector(getServerLimitsSelector);
|
||||
const dispatch = useDispatch();
|
||||
const [requested, setRequested] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// All logged-in users can fetch server limits (server handles permission filtering)
|
||||
if (isLoggedIn && !requested) {
|
||||
dispatch(getServerLimitsAction());
|
||||
setRequested(true);
|
||||
}
|
||||
}, [isLoggedIn, requested, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
// Mark as loaded when we have server limits data
|
||||
if (serverLimits && (serverLimits.postHistoryLimit !== undefined || serverLimits.activeUserCount >= 0)) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [serverLimits]);
|
||||
|
||||
const result: [ServerLimits, boolean] = useMemo(() => {
|
||||
return [serverLimits, loaded];
|
||||
}, [serverLimits, loaded]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -5,10 +5,8 @@ import {connect} from 'react-redux';
|
|||
import {bindActionCreators} from 'redux';
|
||||
import type {Dispatch} from 'redux';
|
||||
|
||||
import {getCloudLimits, getCloudLimitsLoaded} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getCurrentChannelId, getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
|
||||
import {getLimitedViews, getPost} from 'mattermost-redux/selectors/entities/posts';
|
||||
import {getUsage} from 'mattermost-redux/selectors/entities/usage';
|
||||
|
||||
import {emitShortcutReactToLastPostFrom} from 'actions/post_actions';
|
||||
import {getShortcutReactToLastPostEmittedFrom} from 'selectors/emojis';
|
||||
|
|
@ -24,26 +22,20 @@ type OwnProps = Pick<PostListRowProps, 'listId'>
|
|||
|
||||
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
|
||||
const shortcutReactToLastPostEmittedFrom = getShortcutReactToLastPostEmittedFrom(state);
|
||||
const usage = getUsage(state);
|
||||
const limits = getCloudLimits(state);
|
||||
const limitsLoaded = getCloudLimitsLoaded(state);
|
||||
const post = getPost(state, ownProps.listId);
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const newMessagesSeparatorActions = state.plugins.components.NewMessagesSeparatorAction;
|
||||
|
||||
const props: Pick<
|
||||
PostListRowProps,
|
||||
'shortcutReactToLastPostEmittedFrom' | 'usage' | 'limits' | 'limitsLoaded' | 'exceededLimitChannelId' | 'firstInaccessiblePostTime' | 'post' | 'currentUserId' | 'newMessagesSeparatorActions'
|
||||
'shortcutReactToLastPostEmittedFrom'| 'exceededLimitChannelId' | 'firstInaccessiblePostTime' | 'post' | 'currentUserId' | 'newMessagesSeparatorActions'
|
||||
> = {
|
||||
shortcutReactToLastPostEmittedFrom,
|
||||
usage,
|
||||
limits,
|
||||
limitsLoaded,
|
||||
post,
|
||||
currentUserId,
|
||||
newMessagesSeparatorActions,
|
||||
};
|
||||
if ((ownProps.listId === PostListRowListIds.OLDER_MESSAGES_LOADER || ownProps.listId === PostListRowListIds.CHANNEL_INTRO_MESSAGE) && limitsLoaded) {
|
||||
if ((ownProps.listId === PostListRowListIds.OLDER_MESSAGES_LOADER || ownProps.listId === PostListRowListIds.CHANNEL_INTRO_MESSAGE)) {
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
const firstInaccessiblePostTime = getLimitedViews(state).channels[currentChannelId];
|
||||
const channelLimitExceeded = Boolean(firstInaccessiblePostTime) || firstInaccessiblePostTime === 0;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import classNames from 'classnames';
|
|||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import type {CloudUsage, Limits} from '@mattermost/types/cloud';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
|
|
@ -51,10 +50,6 @@ export type PostListRowProps = {
|
|||
*/
|
||||
loadingNewerPosts: boolean;
|
||||
loadingOlderPosts: boolean;
|
||||
|
||||
usage: CloudUsage;
|
||||
limits: Limits;
|
||||
limitsLoaded: boolean;
|
||||
exceededLimitChannelId?: string;
|
||||
firstInaccessiblePostTime?: number;
|
||||
channelId: string;
|
||||
|
|
@ -125,10 +120,7 @@ export default class PostListRow extends React.PureComponent<PostListRowProps> {
|
|||
|
||||
if (this.props.exceededLimitChannelId) {
|
||||
return (
|
||||
<CenterMessageLock
|
||||
channelId={this.props.exceededLimitChannelId}
|
||||
firstInaccessiblePostTime={this.props.firstInaccessiblePostTime}
|
||||
/>
|
||||
<CenterMessageLock/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
.SearchLimitsBanner__wrapper {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import {Provider} from 'react-redux';
|
|||
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import mockStore from 'tests/test_store';
|
||||
import {CloudProducts} from 'utils/constants';
|
||||
import {DataSearchTypes} from 'utils/constants';
|
||||
import {FileSizes} from 'utils/file_utils';
|
||||
import {makeEmptyUsage} from 'utils/limits_test';
|
||||
|
||||
|
|
@ -37,35 +37,13 @@ const limits = {
|
|||
},
|
||||
};
|
||||
|
||||
const products = {
|
||||
prod_1: {
|
||||
id: 'prod_1',
|
||||
sku: CloudProducts.STARTER,
|
||||
price_per_seat: 0,
|
||||
name: 'Cloud Free',
|
||||
},
|
||||
prod_2: {
|
||||
id: 'prod_2',
|
||||
sku: CloudProducts.PROFESSIONAL,
|
||||
price_per_seat: 10,
|
||||
name: 'Cloud Professional',
|
||||
},
|
||||
prod_3: {
|
||||
id: 'prod_3',
|
||||
sku: CloudProducts.ENTERPRISE,
|
||||
price_per_seat: 30,
|
||||
name: 'Cloud Enterprise',
|
||||
},
|
||||
};
|
||||
|
||||
describe('components/select_results/SearchLimitsBanner', () => {
|
||||
test('should NOT show banner for non cloud when doing messages search', () => {
|
||||
test('should NOT show banner for no limits when doing messages search', () => {
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'false', // not cloud
|
||||
},
|
||||
},
|
||||
users: {
|
||||
|
|
@ -74,124 +52,32 @@ describe('components/select_results/SearchLimitsBanner', () => {
|
|||
uid: {},
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
limits: {
|
||||
limits: {},
|
||||
limitsLoaded: false,
|
||||
},
|
||||
limits: {
|
||||
serverLimits: undefined,
|
||||
},
|
||||
search: {
|
||||
results: [],
|
||||
flagged: [],
|
||||
isSearchingTerm: false,
|
||||
isSearchGettingMore: false,
|
||||
matches: {},
|
||||
recent: {},
|
||||
current: {},
|
||||
truncationInfo: undefined,
|
||||
},
|
||||
usage,
|
||||
},
|
||||
views: {
|
||||
rhs: {
|
||||
rhsState: null, // No RHS state
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = mockStore(state);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType='messages'/></Provider>);
|
||||
expect(wrapper.find('#messages_search_limits_banner').exists()).toEqual(false);
|
||||
});
|
||||
|
||||
test('should NOT show banner for non cloud when doing files search', () => {
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'false', // not cloud
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'uid',
|
||||
profiles: {
|
||||
uid: {},
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
limits: {
|
||||
limits: {},
|
||||
limitsLoaded: false,
|
||||
},
|
||||
},
|
||||
usage,
|
||||
},
|
||||
};
|
||||
const store = mockStore(state);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType='files'/></Provider>);
|
||||
expect(wrapper.find('#files_search_limits_banner').exists()).toEqual(false);
|
||||
});
|
||||
|
||||
test('should NOT show banner for cloud when doing cloud messages search for product without limits', () => {
|
||||
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
|
||||
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10k
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true',
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'uid',
|
||||
profiles: {
|
||||
uid: {},
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {
|
||||
is_free_trial: 'false',
|
||||
product_id: 'prod_3', // enterprise
|
||||
},
|
||||
products,
|
||||
limits: {
|
||||
limits: {},
|
||||
limitsLoaded: false,
|
||||
},
|
||||
},
|
||||
usage: aboveMessagesLimitUsage,
|
||||
},
|
||||
};
|
||||
const store = mockStore(state);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType='messages'/></Provider>);
|
||||
expect(wrapper.find('#messages_search_limits_banner').exists()).toEqual(false);
|
||||
});
|
||||
|
||||
test('should NOT show banner for cloud when doing cloud files search for product without limits', () => {
|
||||
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
|
||||
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10k
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true',
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'uid',
|
||||
profiles: {
|
||||
uid: {},
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {
|
||||
is_free_trial: 'false',
|
||||
product_id: 'prod_3', // enterprise
|
||||
},
|
||||
products,
|
||||
limits: {
|
||||
limits: {},
|
||||
limitsLoaded: false,
|
||||
},
|
||||
},
|
||||
usage: aboveMessagesLimitUsage,
|
||||
},
|
||||
};
|
||||
const store = mockStore(state);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType='files'/></Provider>);
|
||||
expect(wrapper.find('#files_search_limits_banner').exists()).toEqual(false);
|
||||
});
|
||||
|
||||
test('should show banner for CLOUD when doing cloud messages search above the limit in Free product', () => {
|
||||
test('should show banner when doing messages search above the limit in Entry with limits', () => {
|
||||
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
|
||||
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10K
|
||||
|
||||
|
|
@ -200,7 +86,6 @@ describe('components/select_results/SearchLimitsBanner', () => {
|
|||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true', // cloud
|
||||
},
|
||||
},
|
||||
users: {
|
||||
|
|
@ -214,10 +99,32 @@ describe('components/select_results/SearchLimitsBanner', () => {
|
|||
is_free_trial: 'true',
|
||||
product_id: 'prod_1', // free
|
||||
},
|
||||
products,
|
||||
limits,
|
||||
},
|
||||
limits: {
|
||||
serverLimits: {
|
||||
postHistoryLimit: 10000,
|
||||
},
|
||||
},
|
||||
usage: aboveMessagesLimitUsage,
|
||||
search: {
|
||||
results: [],
|
||||
flagged: [],
|
||||
isSearchingTerm: false,
|
||||
isSearchGettingMore: false,
|
||||
matches: {},
|
||||
recent: {},
|
||||
current: {},
|
||||
truncationInfo: {
|
||||
posts: 1, // Indicate that search is truncated
|
||||
files: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
rhs: {
|
||||
rhsState: 'search', // RHS showing search results
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = mockStore(state);
|
||||
|
|
@ -225,16 +132,16 @@ describe('components/select_results/SearchLimitsBanner', () => {
|
|||
expect(wrapper.find('#messages_search_limits_banner').exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('should show banner for CLOUD when doing cloud files search above the limit in Free product', () => {
|
||||
const aboveFilesLimitUsage = JSON.parse(JSON.stringify(usage));
|
||||
aboveFilesLimitUsage.files.totalStorage = 1.1 * FileSizes.Gigabyte; // above limit of 1GB
|
||||
test('should display "View plans" CTA text for messages search when banner is shown', () => {
|
||||
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
|
||||
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10K
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true', // cloud
|
||||
Cloud: 'true',
|
||||
},
|
||||
},
|
||||
users: {
|
||||
|
|
@ -248,27 +155,52 @@ describe('components/select_results/SearchLimitsBanner', () => {
|
|||
is_free_trial: 'true',
|
||||
product_id: 'prod_1', // free
|
||||
},
|
||||
products,
|
||||
limits,
|
||||
},
|
||||
usage: aboveFilesLimitUsage,
|
||||
limits: {
|
||||
serverLimits: {
|
||||
postHistoryLimit: 10000,
|
||||
},
|
||||
},
|
||||
usage: aboveMessagesLimitUsage,
|
||||
search: {
|
||||
results: [],
|
||||
flagged: [],
|
||||
isSearchingTerm: false,
|
||||
isSearchGettingMore: false,
|
||||
matches: {},
|
||||
recent: {},
|
||||
current: {},
|
||||
truncationInfo: {
|
||||
posts: 1, // Indicate that search is truncated
|
||||
files: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
rhs: {
|
||||
rhsState: 'search', // RHS showing search results
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const store = mockStore(state);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType='files'/></Provider>);
|
||||
expect(wrapper.find('#files_search_limits_banner').exists()).toEqual(true);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType={DataSearchTypes.MESSAGES_SEARCH_TYPE}/></Provider>);
|
||||
|
||||
expect(wrapper.find('#messages_search_limits_banner').exists()).toEqual(true);
|
||||
expect(wrapper.text()).toContain('paid plans');
|
||||
});
|
||||
|
||||
test('should not show banner for CLOUD when doing cloud files search above the limit in PROFESSIONAL product', () => {
|
||||
const aboveFilesLimitUsage = JSON.parse(JSON.stringify(usage));
|
||||
aboveFilesLimitUsage.files.totalStorage = 1.1 * FileSizes.Gigabyte; // above limit of 1GB. This limit is higher in professional
|
||||
test('should display correct banner message format for messages search', () => {
|
||||
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
|
||||
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10K
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true', // cloud
|
||||
Cloud: 'true',
|
||||
},
|
||||
},
|
||||
users: {
|
||||
|
|
@ -280,19 +212,167 @@ describe('components/select_results/SearchLimitsBanner', () => {
|
|||
cloud: {
|
||||
subscription: {
|
||||
is_free_trial: 'true',
|
||||
product_id: 'prod_2', // professional
|
||||
product_id: 'prod_1', // free
|
||||
},
|
||||
products,
|
||||
limits: {
|
||||
limits: {},
|
||||
limitsLoaded: false,
|
||||
limits,
|
||||
},
|
||||
limits: {
|
||||
serverLimits: {
|
||||
postHistoryLimit: 10000,
|
||||
},
|
||||
},
|
||||
usage: aboveFilesLimitUsage,
|
||||
usage: aboveMessagesLimitUsage,
|
||||
search: {
|
||||
results: [],
|
||||
flagged: [],
|
||||
isSearchingTerm: false,
|
||||
isSearchGettingMore: false,
|
||||
matches: {},
|
||||
recent: {},
|
||||
current: {},
|
||||
truncationInfo: {
|
||||
posts: 1, // Indicate that search is truncated
|
||||
files: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
rhs: {
|
||||
rhsState: 'search', // RHS showing search results
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const store = mockStore(state);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType='files'/></Provider>);
|
||||
expect(wrapper.find('#files_search_limits_banner').exists()).toEqual(false);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType={DataSearchTypes.MESSAGES_SEARCH_TYPE}/></Provider>);
|
||||
|
||||
const bannerText = wrapper.text();
|
||||
expect(bannerText).toContain('Limited history is displayed');
|
||||
expect(bannerText).toContain('Full access to message history is included in');
|
||||
});
|
||||
|
||||
test('should render CTA link correctly when banner is shown', () => {
|
||||
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
|
||||
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10K
|
||||
|
||||
// Test focuses on verifying component renders correctly with proper CTA
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true',
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'uid',
|
||||
profiles: {
|
||||
uid: {},
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {
|
||||
is_free_trial: 'true',
|
||||
product_id: 'prod_1', // free
|
||||
},
|
||||
limits,
|
||||
},
|
||||
limits: {
|
||||
serverLimits: {
|
||||
postHistoryLimit: 10000,
|
||||
},
|
||||
},
|
||||
usage: aboveMessagesLimitUsage,
|
||||
search: {
|
||||
results: [],
|
||||
flagged: [],
|
||||
isSearchingTerm: false,
|
||||
isSearchGettingMore: false,
|
||||
matches: {},
|
||||
recent: {},
|
||||
current: {},
|
||||
truncationInfo: {
|
||||
posts: 1, // Indicate that search is truncated
|
||||
files: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
rhs: {
|
||||
rhsState: 'search', // RHS showing search results
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const store = mockStore(state);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType={DataSearchTypes.MESSAGES_SEARCH_TYPE}/></Provider>);
|
||||
|
||||
// Verify the banner is shown and contains the CTA link
|
||||
expect(wrapper.find('#messages_search_limits_banner').exists()).toEqual(true);
|
||||
expect(wrapper.text()).toContain('paid plans');
|
||||
|
||||
// Find the CTA link
|
||||
const ctaLink = wrapper.find('a');
|
||||
expect(ctaLink).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should NOT show banner when RHS is showing pinned posts even with truncated search results', () => {
|
||||
const aboveMessagesLimitUsage = JSON.parse(JSON.stringify(usage));
|
||||
aboveMessagesLimitUsage.messages.history = 15000; // above limit of 10K
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true',
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'uid',
|
||||
profiles: {
|
||||
uid: {},
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {
|
||||
is_free_trial: 'true',
|
||||
product_id: 'prod_1', // free
|
||||
},
|
||||
limits,
|
||||
},
|
||||
limits: {
|
||||
serverLimits: {
|
||||
postHistoryLimit: 10000,
|
||||
},
|
||||
},
|
||||
usage: aboveMessagesLimitUsage,
|
||||
search: {
|
||||
results: [],
|
||||
flagged: [],
|
||||
isSearchingTerm: false,
|
||||
isSearchGettingMore: false,
|
||||
matches: {},
|
||||
recent: {},
|
||||
current: {},
|
||||
truncationInfo: {
|
||||
posts: 1, // Search is truncated, but...
|
||||
files: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
rhs: {
|
||||
rhsState: 'pin', // RHS showing pinned posts, not search results
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const store = mockStore(state);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><SearchLimitsBanner searchType={DataSearchTypes.MESSAGES_SEARCH_TYPE}/></Provider>);
|
||||
|
||||
// Banner should NOT show because RHS is showing pinned posts, not search results
|
||||
expect(wrapper.find('#messages_search_limits_banner').exists()).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,145 +2,51 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
|
||||
import {isAdmin} from 'mattermost-redux/utils/user_utils';
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
import useGetLimits from 'components/common/hooks/useGetLimits';
|
||||
import useGetUsage from 'components/common/hooks/useGetUsage';
|
||||
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
|
||||
import {isSearchTruncated} from 'mattermost-redux/selectors/entities/search';
|
||||
|
||||
import {DataSearchTypes} from 'utils/constants';
|
||||
import {asGBString} from 'utils/limits';
|
||||
import {getRhsState} from 'selectors/rhs';
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
import CenterMessageLock from 'components/center_message_lock';
|
||||
import useGetServerLimits from 'components/common/hooks/useGetServerLimits';
|
||||
|
||||
const StyledA = styled.a`
|
||||
color: var(--button-bg) !important;
|
||||
`;
|
||||
import {DataSearchTypes, RHSStates} from 'utils/constants';
|
||||
|
||||
const InnerDiv = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
padding: 10px;
|
||||
margin: 10px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0.02em;
|
||||
`;
|
||||
import './search_limits_banner.scss';
|
||||
|
||||
type Props = {
|
||||
searchType: string;
|
||||
}
|
||||
|
||||
function SearchLimitsBanner(props: Props) {
|
||||
const {formatMessage, formatNumber} = useIntl();
|
||||
const {openPricingModal, isAirGapped} = useOpenPricingModal();
|
||||
const usage = useGetUsage();
|
||||
const [cloudLimits] = useGetLimits();
|
||||
const isAdminUser = isAdmin(useSelector(getCurrentUser).roles);
|
||||
const isCloud = useSelector(isCurrentLicenseCloud);
|
||||
const [serverLimits] = useGetServerLimits();
|
||||
|
||||
if (!isCloud) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentFileStorageUsage = usage.files.totalStorage;
|
||||
const fileStorageLimit = cloudLimits?.files?.total_storage;
|
||||
const currentMessagesUsage = usage.messages.history;
|
||||
const messagesLimit = cloudLimits?.messages?.history;
|
||||
|
||||
let ctaAction = formatMessage({
|
||||
id: 'workspace_limits.search_limit.view_plans',
|
||||
defaultMessage: 'View plans',
|
||||
// Check if current search results were actually truncated
|
||||
const searchTruncated = useSelector((state: GlobalState) => {
|
||||
const searchType = props.searchType === DataSearchTypes.FILES_SEARCH_TYPE ? 'files' : 'posts';
|
||||
return isSearchTruncated(state, searchType);
|
||||
});
|
||||
|
||||
if (isAdminUser) {
|
||||
ctaAction = formatMessage({
|
||||
id: 'workspace_limits.search_limit.upgrade_now',
|
||||
defaultMessage: 'Upgrade now',
|
||||
});
|
||||
}
|
||||
// Check if RHS is currently showing search results
|
||||
const rhsState = useSelector(getRhsState);
|
||||
const isShowingSearchResults = rhsState === RHSStates.SEARCH;
|
||||
|
||||
const renderBanner = (bannerText: React.ReactNode, id: string) => {
|
||||
return (<StyledDiv id={id}>
|
||||
<InnerDiv>
|
||||
<i className='icon-eye-off-outline'/>
|
||||
<span>{bannerText}</span>
|
||||
</InnerDiv>
|
||||
</StyledDiv>);
|
||||
};
|
||||
|
||||
switch (props.searchType) {
|
||||
case DataSearchTypes.FILES_SEARCH_TYPE: {
|
||||
if ((fileStorageLimit === undefined) || !(currentFileStorageUsage > fileStorageLimit)) {
|
||||
return null;
|
||||
}
|
||||
const filesBannerMessage = isAirGapped ?
|
||||
formatMessage({
|
||||
id: 'workspace_limits.search_files_limit.banner_text_airgapped',
|
||||
defaultMessage: 'Some older files may not be shown because your workspace has met its file storage limit of {storage}.',
|
||||
}, {
|
||||
storage: asGBString(fileStorageLimit, formatNumber),
|
||||
}) :
|
||||
formatMessage({
|
||||
id: 'workspace_limits.search_files_limit.banner_text',
|
||||
defaultMessage: 'Some older files may not be shown because your workspace has met its file storage limit of {storage}. <a>{ctaAction}</a>',
|
||||
}, {
|
||||
ctaAction,
|
||||
storage: asGBString(fileStorageLimit, formatNumber),
|
||||
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
|
||||
<StyledA
|
||||
onClick={() => openPricingModal()}
|
||||
>
|
||||
{chunks}
|
||||
</StyledA>
|
||||
),
|
||||
});
|
||||
return renderBanner(filesBannerMessage, `${DataSearchTypes.FILES_SEARCH_TYPE}_search_limits_banner`);
|
||||
}
|
||||
|
||||
case DataSearchTypes.MESSAGES_SEARCH_TYPE: {
|
||||
if ((messagesLimit === undefined) || !(currentMessagesUsage > messagesLimit)) {
|
||||
return null;
|
||||
}
|
||||
const messagesBannerMessage = isAirGapped ?
|
||||
formatMessage({
|
||||
id: 'workspace_limits.search_message_limit.banner_text_airgapped',
|
||||
defaultMessage: 'Some older messages may not be shown because your workspace has over {messages} messages.',
|
||||
}, {
|
||||
messages: formatNumber(messagesLimit),
|
||||
}) :
|
||||
formatMessage({
|
||||
id: 'workspace_limits.search_message_limit.banner_text',
|
||||
defaultMessage: 'Some older messages may not be shown because your workspace has over {messages} messages. <a>{ctaAction}</a>',
|
||||
}, {
|
||||
ctaAction,
|
||||
messages: formatNumber(messagesLimit),
|
||||
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
|
||||
<StyledA
|
||||
onClick={() => openPricingModal()}
|
||||
>
|
||||
{chunks}
|
||||
</StyledA>
|
||||
),
|
||||
});
|
||||
return renderBanner(messagesBannerMessage, `${DataSearchTypes.MESSAGES_SEARCH_TYPE}_search_limits_banner`);
|
||||
}
|
||||
default:
|
||||
// Only show banner if search results were actually truncated AND currently showing search results
|
||||
if (!searchTruncated || !serverLimits?.postHistoryLimit || !isShowingSearchResults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`${props.searchType}_search_limits_banner`}
|
||||
className='SearchLimitsBanner__wrapper'
|
||||
>
|
||||
<CenterMessageLock/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchLimitsBanner;
|
||||
|
|
|
|||
|
|
@ -6504,13 +6504,8 @@
|
|||
"workspace_limits.menu_limit.warn.files_storage": "You’re getting closer to the {limit} file storage limit. <a>{callToAction}</a>",
|
||||
"workspace_limits.menu_limit.warn.messages_history": "You’re getting closer to the free {limit} message limit. <a>{callToAction}</a>",
|
||||
"workspace_limits.message_history": "Message history",
|
||||
"workspace_limits.message_history.locked.cta.admin": "Upgrade now",
|
||||
"workspace_limits.message_history.locked.cta.end_user": "Notify Admin",
|
||||
"workspace_limits.message_history.locked.description.admin": "To view and search all of the messages in your workspace’s history, rather than just the most recent {limit} messages, upgrade to one of our paid plans. <a>Review our plan options and pricing.</a>",
|
||||
"workspace_limits.message_history.locked.description.admin.airgapped": "To view and search all of the messages in your workspace's history, rather than just the most recent {limit} messages, upgrade to one of our paid plans.",
|
||||
"workspace_limits.message_history.locked.description.end_user": "Some older messages may not be shown because your workspace has over {limit} messages. Select Notify Admin to send an automatic request to your System Admins to upgrade.",
|
||||
"workspace_limits.message_history.locked.title.admin": "Unlock messages prior to {date} in {team}",
|
||||
"workspace_limits.message_history.locked.title.end_user": "Notify your admin to unlock messages prior to {date} in {team}",
|
||||
"workspace_limits.message_history.locked.description.admin": "Full access to message history is included in <a>paid plans</a>",
|
||||
"workspace_limits.message_history.locked.title.admin": "Limited history is displayed",
|
||||
"workspace_limits.message_history.short": "Messages",
|
||||
"workspace_limits.message_history.short.usage": "{actual} / {limit}",
|
||||
"workspace_limits.message_history.usage": "{actual} of {limit} messages ({percent}%)",
|
||||
|
|
@ -6522,12 +6517,6 @@
|
|||
"workspace_limits.modals.limits_reached.title.message_history": "Message history",
|
||||
"workspace_limits.modals.view_plan_options": "View plan options",
|
||||
"workspace_limits.modals.view_plans": "View plans",
|
||||
"workspace_limits.search_files_limit.banner_text": "Some older files may not be shown because your workspace has met its file storage limit of {storage}. <a>{ctaAction}</a>",
|
||||
"workspace_limits.search_files_limit.banner_text_airgapped": "Some older files may not be shown because your workspace has met its file storage limit of {storage}.",
|
||||
"workspace_limits.search_limit.upgrade_now": "Upgrade now",
|
||||
"workspace_limits.search_limit.view_plans": "View plans",
|
||||
"workspace_limits.search_message_limit.banner_text": "Some older messages may not be shown because your workspace has over {messages} messages. <a>{ctaAction}</a>",
|
||||
"workspace_limits.search_message_limit.banner_text_airgapped": "Some older messages may not be shown because your workspace has over {messages} messages.",
|
||||
"workspace_limits.teams_limit_reached.tool_tip": "You've reached the team limit for your current plan. Consider upgrading to unarchive this team or archive your other teams",
|
||||
"workspace_limits.teams_limit_reached.upgrade_to_unarchive": "Upgrade to Unarchive",
|
||||
"workspace_limits.teams_limit_reached.view_upgrade_options": "View upgrade options",
|
||||
|
|
|
|||
|
|
@ -4,5 +4,5 @@
|
|||
import keyMirror from 'mattermost-redux/utils/key_mirror';
|
||||
|
||||
export default keyMirror({
|
||||
RECIEVED_APP_LIMITS: null,
|
||||
RECEIVED_APP_LIMITS: null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export default keyMirror({
|
|||
RECEIVED_SEARCH_FLAGGED_POSTS: null,
|
||||
RECEIVED_SEARCH_PINNED_POSTS: null,
|
||||
RECEIVED_SEARCH_TERM: null,
|
||||
RECEIVED_SEARCH_TRUNCATION_INFO: null,
|
||||
REMOVE_SEARCH_POSTS: null,
|
||||
REMOVE_SEARCH_FILES: null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export default keyMirror({
|
|||
DISABLED_USER_ACCESS_TOKEN: null,
|
||||
ENABLED_USER_ACCESS_TOKEN: null,
|
||||
RECEIVED_USER_STATS: null,
|
||||
RECIEVED_APP_LIMITS: null,
|
||||
RECEIVED_APP_LIMITS: null,
|
||||
RECEIVED_FILTERED_USER_STATS: null,
|
||||
PROFILE_NO_LONGER_VISIBLE: null,
|
||||
LOGIN: null,
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import nock from 'nock';
|
||||
|
||||
import type {ServerLimits} from '@mattermost/types/limits';
|
||||
|
||||
import * as Actions from 'mattermost-redux/actions/limits';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import TestHelper from '../../test/test_helper';
|
||||
import configureStore from '../../test/test_store';
|
||||
|
||||
describe('getServerLimits', () => {
|
||||
const URL_USERS_LIMITS = '/limits/server';
|
||||
|
||||
const defaultServerLimitsState: ServerLimits = {
|
||||
activeUserCount: 0,
|
||||
maxUsersLimit: 0,
|
||||
};
|
||||
|
||||
let store = configureStore();
|
||||
|
||||
beforeAll(() => {
|
||||
TestHelper.initBasic(Client4);
|
||||
Client4.setEnableLogging(true);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
store = configureStore({
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'current_user_id',
|
||||
profiles: {
|
||||
current_user_id: {
|
||||
roles: 'system_admin',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
TestHelper.tearDown();
|
||||
Client4.setEnableLogging(false);
|
||||
});
|
||||
|
||||
test('should return default state for non admin users', async () => {
|
||||
store = configureStore({
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'current_user_id',
|
||||
profiles: {
|
||||
current_user_id: {
|
||||
roles: 'system_user',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const {data} = await store.dispatch(Actions.getServerLimits());
|
||||
expect(data).toEqual(defaultServerLimitsState);
|
||||
});
|
||||
|
||||
test('should not return default state for non admin users', async () => {
|
||||
const {data} = await store.dispatch(Actions.getServerLimits());
|
||||
expect(data).not.toEqual(defaultServerLimitsState);
|
||||
});
|
||||
|
||||
test('should return data if user is admin', async () => {
|
||||
const userLimits: ServerLimits = {
|
||||
activeUserCount: 600,
|
||||
maxUsersLimit: 2_500,
|
||||
|
||||
};
|
||||
|
||||
nock(Client4.getBaseRoute()).
|
||||
get(URL_USERS_LIMITS).
|
||||
reply(200, userLimits);
|
||||
|
||||
const {data} = await store.dispatch(Actions.getServerLimits());
|
||||
expect(data).toEqual(userLimits);
|
||||
});
|
||||
|
||||
test('should return error if the request fails', async () => {
|
||||
const errorMessage = 'test error message';
|
||||
nock(Client4.getBaseRoute()).
|
||||
get(URL_USERS_LIMITS).
|
||||
reply(400, {message: errorMessage});
|
||||
|
||||
const {error} = await store.dispatch(Actions.getServerLimits());
|
||||
console.log(error);
|
||||
expect(error.message).toEqual(errorMessage);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,23 +8,11 @@ import {LimitsTypes} from 'mattermost-redux/action_types';
|
|||
import {logError} from 'mattermost-redux/actions/errors';
|
||||
import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users';
|
||||
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
|
||||
import {isAdmin} from 'mattermost-redux/utils/user_utils';
|
||||
|
||||
export function getServerLimits(): ActionFuncAsync<ServerLimits> {
|
||||
return async (dispatch, getState) => {
|
||||
const roles = getCurrentUserRoles(getState());
|
||||
const amIAdmin = isAdmin(roles);
|
||||
if (!amIAdmin) {
|
||||
return {
|
||||
data: {
|
||||
activeUserCount: 0,
|
||||
maxUsersLimit: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// All users can fetch server limits - server handles permission filtering
|
||||
let response;
|
||||
try {
|
||||
response = await Client4.getServerLimits();
|
||||
|
|
@ -37,9 +25,14 @@ export function getServerLimits(): ActionFuncAsync<ServerLimits> {
|
|||
const data: ServerLimits = {
|
||||
activeUserCount: response?.data?.activeUserCount ?? 0,
|
||||
maxUsersLimit: response?.data?.maxUsersLimit ?? 0,
|
||||
maxUsersHardLimit: response?.data?.maxUsersHardLimit ?? 0,
|
||||
|
||||
// Post history limit fields from server response
|
||||
lastAccessiblePostTime: response?.data?.lastAccessiblePostTime ?? 0,
|
||||
postHistoryLimit: response?.data?.postHistoryLimit ?? 0,
|
||||
};
|
||||
|
||||
dispatch({type: LimitsTypes.RECIEVED_APP_LIMITS, data});
|
||||
dispatch({type: LimitsTypes.RECEIVED_APP_LIMITS, data});
|
||||
|
||||
return {data};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -73,6 +73,18 @@ export function searchPostsWithParams(teamId: string, params: SearchParameter):
|
|||
type: SearchTypes.SEARCH_POSTS_REQUEST,
|
||||
isGettingMore,
|
||||
});
|
||||
|
||||
// Reset truncation info for new searches (not pagination)
|
||||
if (!isGettingMore) {
|
||||
dispatch({
|
||||
type: SearchTypes.RECEIVED_SEARCH_TRUNCATION_INFO,
|
||||
data: {
|
||||
firstInaccessiblePostTime: 0,
|
||||
searchType: 'posts',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let posts;
|
||||
|
||||
try {
|
||||
|
|
@ -108,6 +120,18 @@ export function searchPostsWithParams(teamId: string, params: SearchParameter):
|
|||
},
|
||||
], 'SEARCH_POST_BATCH'));
|
||||
|
||||
// Dispatch truncation info separately to avoid typing conflicts
|
||||
const firstInaccessiblePostTime = posts.first_inaccessible_post_time || 0;
|
||||
if (firstInaccessiblePostTime > 0) {
|
||||
dispatch({
|
||||
type: SearchTypes.RECEIVED_SEARCH_TRUNCATION_INFO,
|
||||
data: {
|
||||
firstInaccessiblePostTime,
|
||||
searchType: 'posts',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {data: posts};
|
||||
};
|
||||
}
|
||||
|
|
@ -145,6 +169,17 @@ export function searchFilesWithParams(teamId: string, params: SearchParameter):
|
|||
isGettingMore,
|
||||
});
|
||||
|
||||
// Reset truncation info for new searches (not pagination)
|
||||
if (!isGettingMore) {
|
||||
dispatch({
|
||||
type: SearchTypes.RECEIVED_SEARCH_TRUNCATION_INFO,
|
||||
data: {
|
||||
firstInaccessiblePostTime: 0,
|
||||
searchType: 'files',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let files: FileSearchResults;
|
||||
try {
|
||||
files = await Client4.searchFilesWithParams(teamId, params);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {LimitsTypes} from 'mattermost-redux/action_types';
|
|||
|
||||
function serverLimits(state = {}, action: MMReduxAction) {
|
||||
switch (action.type) {
|
||||
case LimitsTypes.RECIEVED_APP_LIMITS: {
|
||||
case LimitsTypes.RECEIVED_APP_LIMITS: {
|
||||
const serverLimits = action.data;
|
||||
return {
|
||||
...state,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
PostTypes,
|
||||
ThreadTypes,
|
||||
CloudTypes,
|
||||
LimitsTypes,
|
||||
} from 'mattermost-redux/action_types';
|
||||
import {Posts} from 'mattermost-redux/constants';
|
||||
import * as reducers from 'mattermost-redux/reducers/entities/posts';
|
||||
|
|
@ -4474,5 +4475,53 @@ describe('limitedViews', () => {
|
|||
expect(nextState).toEqual(initialState);
|
||||
expect(nextState).toBe(initialState); // reference equality preserved
|
||||
});
|
||||
|
||||
it(`${LimitsTypes.RECEIVED_APP_LIMITS} clears out limited views if there are no longer post history limits`, () => {
|
||||
const initialState = {...zeroState, channels: {channelId: 123}};
|
||||
const nextState = reducers.limitedViews(initialState, {
|
||||
type: LimitsTypes.RECEIVED_APP_LIMITS,
|
||||
data: {
|
||||
postHistoryLimit: 0,
|
||||
activeUserCount: 100,
|
||||
maxUsersLimit: 0,
|
||||
maxUsersHardLimit: 0,
|
||||
lastAccessiblePostTime: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(nextState).toEqual(zeroState);
|
||||
});
|
||||
|
||||
it(`${LimitsTypes.RECEIVED_APP_LIMITS} clears out limited views if postHistoryLimit is undefined`, () => {
|
||||
const initialState = {...zeroState, channels: {channelId: 123}};
|
||||
const nextState = reducers.limitedViews(initialState, {
|
||||
type: LimitsTypes.RECEIVED_APP_LIMITS,
|
||||
data: {
|
||||
activeUserCount: 100,
|
||||
maxUsersLimit: 0,
|
||||
maxUsersHardLimit: 0,
|
||||
lastAccessiblePostTime: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(nextState).toEqual(zeroState);
|
||||
});
|
||||
|
||||
it(`${LimitsTypes.RECEIVED_APP_LIMITS} preserves limited views if there are still post history limits`, () => {
|
||||
const initialState = {...zeroState, channels: {channelId: 123}};
|
||||
const nextState = reducers.limitedViews(initialState, {
|
||||
type: LimitsTypes.RECEIVED_APP_LIMITS,
|
||||
data: {
|
||||
postHistoryLimit: 1000,
|
||||
activeUserCount: 100,
|
||||
maxUsersLimit: 0,
|
||||
maxUsersHardLimit: 0,
|
||||
lastAccessiblePostTime: 1234567890,
|
||||
},
|
||||
});
|
||||
|
||||
expect(nextState).toEqual(initialState);
|
||||
expect(nextState).toBe(initialState); // reference equality preserved
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import type {
|
|||
} from '@mattermost/types/utilities';
|
||||
|
||||
import type {MMReduxAction} from 'mattermost-redux/action_types';
|
||||
import {ChannelTypes, PostTypes, UserTypes, ThreadTypes, CloudTypes} from 'mattermost-redux/action_types';
|
||||
import {ChannelTypes, PostTypes, UserTypes, ThreadTypes, CloudTypes, LimitsTypes} from 'mattermost-redux/action_types';
|
||||
import {Posts} from 'mattermost-redux/constants';
|
||||
import {comparePosts, isPermalink, shouldUpdatePost} from 'mattermost-redux/utils/post_utils';
|
||||
|
||||
|
|
@ -1513,6 +1513,16 @@ export function limitedViews(
|
|||
}
|
||||
return state;
|
||||
}
|
||||
case LimitsTypes.RECEIVED_APP_LIMITS: {
|
||||
const serverLimits = action.data;
|
||||
|
||||
// If server limits change and there is no post history limit any more (e.g. upgrade to unlimited plan),
|
||||
// this state is stale and should be dumped.
|
||||
if (!serverLimits?.postHistoryLimit || serverLimits.postHistoryLimit <= 0) {
|
||||
return zeroStateLimitedViews;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case ChannelTypes.LEAVE_CHANNEL: {
|
||||
const channelId = action.data.id;
|
||||
if (!state.channels[channelId]) {
|
||||
|
|
|
|||
|
|
@ -268,6 +268,32 @@ function isLimitedResults(state = -1, action: MMReduxAction): number {
|
|||
}
|
||||
}
|
||||
|
||||
function truncationInfo(state = {posts: 0, files: 0}, action: MMReduxAction) {
|
||||
switch (action.type) {
|
||||
case SearchTypes.RECEIVED_SEARCH_TRUNCATION_INFO: {
|
||||
const {firstInaccessiblePostTime, searchType} = action.data;
|
||||
return {
|
||||
...state,
|
||||
[searchType]: firstInaccessiblePostTime,
|
||||
};
|
||||
}
|
||||
case SearchTypes.REMOVE_SEARCH_POSTS:
|
||||
return {
|
||||
...state,
|
||||
posts: 0,
|
||||
};
|
||||
case SearchTypes.REMOVE_SEARCH_FILES:
|
||||
return {
|
||||
...state,
|
||||
files: 0,
|
||||
};
|
||||
case UserTypes.LOGOUT_SUCCESS:
|
||||
return {posts: 0, files: 0};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
|
||||
// An ordered array with posts ids of flagged posts
|
||||
|
|
@ -297,4 +323,7 @@ export default combineReducers({
|
|||
// Boolean true if the search returns results inaccessible because
|
||||
// they are beyond a cloud workspace's message limits.
|
||||
isLimitedResults,
|
||||
|
||||
// Object tracking truncation info for posts and files separately
|
||||
truncationInfo,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,3 +7,18 @@ import type {GlobalState} from '@mattermost/types/store';
|
|||
export function getServerLimits(state: GlobalState): ServerLimits {
|
||||
return state.entities.limits.serverLimits;
|
||||
}
|
||||
|
||||
// Add new selectors for post history limits
|
||||
export function getPostHistoryLimit(state: GlobalState): number {
|
||||
const limits = getServerLimits(state);
|
||||
return limits?.postHistoryLimit ?? 0;
|
||||
}
|
||||
|
||||
export function hasPostHistoryLimit(state: GlobalState): boolean {
|
||||
const limits = getServerLimits(state);
|
||||
return (limits?.postHistoryLimit ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function shouldShowPostHistoryLimits(state: GlobalState): boolean {
|
||||
return getPostHistoryLimit(state) > 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,3 +26,12 @@ export const getAllUserMentionKeys: (state: GlobalState) => UserMentionKey[] = c
|
|||
return userMentionKeys.concat(groupMentionKeys);
|
||||
},
|
||||
);
|
||||
|
||||
export const getSearchTruncationInfo = (state: GlobalState) => {
|
||||
return state.entities.search.truncationInfo;
|
||||
};
|
||||
|
||||
export const isSearchTruncated = (state: GlobalState, searchType: 'posts' | 'files'): boolean => {
|
||||
const truncationInfo = getSearchTruncationInfo(state);
|
||||
return Boolean(truncationInfo && truncationInfo[searchType] > 0);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,4 +8,9 @@ export type LimitsState = {
|
|||
export type ServerLimits = {
|
||||
activeUserCount: number;
|
||||
maxUsersLimit: number;
|
||||
};
|
||||
maxUsersHardLimit?: number;
|
||||
|
||||
// Post history limit fields
|
||||
lastAccessiblePostTime?: number; // Timestamp of the last accessible post (0 if no limits)
|
||||
postHistoryLimit?: number; // The actual message history limit value (0 if no limits)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ export type SearchState = {
|
|||
matches: {
|
||||
[x: string]: string[];
|
||||
};
|
||||
truncationInfo?: {
|
||||
posts: number;
|
||||
files: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SearchParameter = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue