Automatic channel category sorting (#30866)
* Automatic channel category sorting * Fix types * AIed * Fix issue where categories are updated for all users * Move all logic to server, clean up * PR feedback * Fix lint --------- Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
c6a11763a8
Коммит
25a4839a9e
@@ -181,6 +181,8 @@ func (a *App) CreateChannelWithUser(c request.CTX, channel *model.Channel, userI
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.addChannelToDefaultCategory(c, userID, channel)
|
||||
|
||||
var user *model.User
|
||||
if user, err = a.GetUser(userID); err != nil {
|
||||
return nil, err
|
||||
@@ -208,6 +210,10 @@ func (a *App) RenameChannel(c request.CTX, channel *model.Channel, newChannelNam
|
||||
return nil, model.NewAppError("RenameChannel", "api.channel.rename_channel.cant_rename_group_messages.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Clean up the channel name and display name
|
||||
newChannelName = strings.TrimSpace(newChannelName)
|
||||
newDisplayName = strings.TrimSpace(newDisplayName)
|
||||
|
||||
channel.Name = newChannelName
|
||||
if newDisplayName != "" {
|
||||
channel.DisplayName = newDisplayName
|
||||
@@ -222,6 +228,8 @@ func (a *App) RenameChannel(c request.CTX, channel *model.Channel, newChannelNam
|
||||
}
|
||||
|
||||
func (a *App) CreateChannel(c request.CTX, channel *model.Channel, addMember bool) (*model.Channel, *model.AppError) {
|
||||
a.handleChannelCategoryName(channel)
|
||||
|
||||
channel.DisplayName = strings.TrimSpace(channel.DisplayName)
|
||||
sc, nErr := a.Srv().Store().Channel().Save(c, channel, *a.Config().TeamSettings.MaxChannelsPerTeam)
|
||||
if nErr != nil {
|
||||
@@ -864,11 +872,14 @@ func (a *App) PatchChannel(c request.CTX, channel *model.Channel, patch *model.C
|
||||
oldChannelPurpose := channel.Purpose
|
||||
|
||||
channel.Patch(patch)
|
||||
a.handleChannelCategoryName(channel)
|
||||
channel, err := a.UpdateChannel(c, channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.addChannelToDefaultCategory(c, userID, channel)
|
||||
|
||||
if oldChannelDisplayName != channel.DisplayName {
|
||||
if err = a.PostUpdateChannelDisplayNameMessage(c, userID, channel, oldChannelDisplayName, channel.DisplayName); err != nil {
|
||||
c.Logger().Warn(err.Error())
|
||||
@@ -1665,6 +1676,8 @@ func (a *App) AddUserToChannel(c request.CTX, user *model.User, channel *model.C
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.addChannelToDefaultCategory(c, user.Id, channel)
|
||||
|
||||
// We are sending separate websocket events to the user added and to the channel
|
||||
// This is to get around potential cluster syncing issues where other nodes may not receive the most up to date channel members
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventUserAdded, "", channel.Id, "", map[string]bool{user.Id: true}, "")
|
||||
@@ -3878,3 +3891,56 @@ func (a *App) ChannelAccessControlled(c request.CTX, channelID string) (bool, *m
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) handleChannelCategoryName(channel *model.Channel) {
|
||||
if *a.Config().ExperimentalSettings.ExperimentalChannelCategorySorting && strings.Contains(channel.DisplayName, "/") {
|
||||
parts := strings.Split(channel.DisplayName, "/")
|
||||
channel.DisplayName = strings.TrimSpace(strings.Join(parts[1:], "/"))
|
||||
channel.DefaultCategoryName = strings.TrimSpace(parts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) addChannelToDefaultCategory(c request.CTX, userID string, channel *model.Channel) {
|
||||
// Add channel to default category if specified
|
||||
if channel.DefaultCategoryName != "" && *a.Config().ExperimentalSettings.ExperimentalChannelCategorySorting {
|
||||
// Get user's categories for this team
|
||||
categories, err := a.GetSidebarCategoriesForTeamForUser(c, userID, channel.TeamId)
|
||||
if err != nil {
|
||||
mlog.Error("Failed to get sidebar categories", mlog.String("user_id", userID), mlog.String("team_id", channel.TeamId), mlog.Err(err))
|
||||
return
|
||||
}
|
||||
// Find or create the category
|
||||
var targetCategory *model.SidebarCategoryWithChannels
|
||||
for _, category := range categories.Categories {
|
||||
if category.Type == model.SidebarCategoryCustom && strings.EqualFold(category.DisplayName, channel.DefaultCategoryName) {
|
||||
targetCategory = category
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetCategory == nil {
|
||||
// Create new category if it doesn't exist
|
||||
targetCategory = &model.SidebarCategoryWithChannels{
|
||||
SidebarCategory: model.SidebarCategory{
|
||||
UserId: userID,
|
||||
TeamId: channel.TeamId,
|
||||
Type: model.SidebarCategoryCustom,
|
||||
DisplayName: channel.DefaultCategoryName,
|
||||
Sorting: model.SidebarCategorySortDefault,
|
||||
},
|
||||
Channels: []string{channel.Id},
|
||||
}
|
||||
_, err = a.CreateSidebarCategory(c, userID, channel.TeamId, targetCategory)
|
||||
if err != nil {
|
||||
mlog.Error("Failed to create default category", mlog.String("user_id", userID), mlog.String("team_id", channel.TeamId), mlog.String("category_name", channel.DefaultCategoryName), mlog.Err(err))
|
||||
}
|
||||
} else {
|
||||
// Add channel to existing category
|
||||
targetCategory.Channels = append([]string{channel.Id}, targetCategory.Channels...)
|
||||
_, err = a.UpdateSidebarCategories(c, userID, channel.TeamId, []*model.SidebarCategoryWithChannels{targetCategory})
|
||||
if err != nil {
|
||||
mlog.Error("Failed to update default category", mlog.String("user_id", userID), mlog.String("team_id", channel.TeamId), mlog.String("category_name", channel.DefaultCategoryName), mlog.Err(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3395,3 +3395,139 @@ func TestPatchChannel(t *testing.T) {
|
||||
require.Equal(t, "model.channel.is_valid.banner_info.channel_type.app_error", appErr.Id)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateChannelWithCategorySorting(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
// Enable ExperimentalChannelCategorySorting
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ExperimentalSettings.ExperimentalChannelCategorySorting = true
|
||||
})
|
||||
|
||||
t.Run("should set category when adding user to channel with category and trim white spaces", func(t *testing.T) {
|
||||
channel := &model.Channel{
|
||||
DisplayName: " Category / Channel Name ",
|
||||
Name: "name1",
|
||||
Type: model.ChannelTypeOpen,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
}
|
||||
|
||||
channel, appErr := th.App.CreateChannelWithUser(th.Context, channel, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Equal(t, "Channel Name", channel.DisplayName)
|
||||
require.Equal(t, "Category", channel.DefaultCategoryName)
|
||||
|
||||
// Verify channel is in default category
|
||||
categories, appErr := th.App.GetSidebarCategoriesForTeamForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
foundCategory := false
|
||||
for _, category := range categories.Categories {
|
||||
if category.DisplayName == "Category" {
|
||||
foundCategory = true
|
||||
assert.Contains(t, category.Channels, channel.Id)
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundCategory, "Category 'Category' not found in sidebar categories")
|
||||
|
||||
// Add user to channel
|
||||
_, appErr = th.App.AddUserToChannel(th.Context, th.BasicUser2, channel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Verify channel is in default category
|
||||
categories2, appErr := th.App.GetSidebarCategoriesForTeamForUser(th.Context, th.BasicUser2.Id, th.BasicTeam.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
foundCategory2 := false
|
||||
for _, category := range categories2.Categories {
|
||||
if category.DisplayName == "Category" {
|
||||
foundCategory2 = true
|
||||
assert.Contains(t, category.Channels, channel.Id)
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundCategory2, "Category 'Category' not found in sidebar categories")
|
||||
})
|
||||
|
||||
t.Run("should not set category when feature is disabled", func(t *testing.T) {
|
||||
channel := &model.Channel{
|
||||
DisplayName: "Category2/Channel Name",
|
||||
Name: "name2",
|
||||
Type: model.ChannelTypeOpen,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
}
|
||||
|
||||
channel, appErr := th.App.CreateChannel(th.Context, channel, false)
|
||||
require.Nil(t, appErr)
|
||||
require.Equal(t, "Channel Name", channel.DisplayName)
|
||||
require.Equal(t, "Category2", channel.DefaultCategoryName)
|
||||
|
||||
// Disable ExperimentalChannelCategorySorting
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ExperimentalSettings.ExperimentalChannelCategorySorting = false
|
||||
})
|
||||
|
||||
// Add user to channel
|
||||
_, appErr = th.App.AddUserToChannel(th.Context, th.BasicUser, channel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Verify channel is in default category
|
||||
categories, appErr := th.App.GetSidebarCategoriesForTeamForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
foundCategory := false
|
||||
for _, category := range categories.Categories {
|
||||
if category.DisplayName == "Category2" {
|
||||
foundCategory = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.False(t, foundCategory, "Category 'Category2' not found in sidebar categories")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchChannelWithCategorySorting(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
// Enable ExperimentalChannelCategorySorting
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ExperimentalSettings.ExperimentalChannelCategorySorting = true
|
||||
})
|
||||
|
||||
// Create initial channel
|
||||
channel := th.createChannel(th.Context, th.BasicTeam, model.ChannelTypeOpen)
|
||||
channel.DisplayName = "Initial Name"
|
||||
channel, appErr := th.App.UpdateChannel(th.Context, channel)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Add user to channel
|
||||
_, appErr = th.App.AddUserToChannel(th.Context, th.BasicUser, channel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Patch channel with new display name containing category
|
||||
patch := &model.ChannelPatch{
|
||||
DisplayName: model.NewPointer(" New Category / New Channel Name "),
|
||||
}
|
||||
|
||||
patchedChannel, appErr := th.App.PatchChannel(th.Context, channel, patch, channel.CreatorId)
|
||||
require.Nil(t, appErr)
|
||||
require.Equal(t, "New Channel Name", patchedChannel.DisplayName)
|
||||
require.Equal(t, "New Category", patchedChannel.DefaultCategoryName)
|
||||
|
||||
// Test that category is not updated when feature is disabled
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ExperimentalSettings.ExperimentalChannelCategorySorting = false
|
||||
})
|
||||
|
||||
patch = &model.ChannelPatch{
|
||||
DisplayName: model.NewPointer("Disabled Category/Channel Name"),
|
||||
}
|
||||
|
||||
patchedChannel, appErr = th.App.PatchChannel(th.Context, channel, patch, channel.CreatorId)
|
||||
require.Nil(t, appErr)
|
||||
require.Equal(t, "Disabled Category/Channel Name", patchedChannel.DisplayName)
|
||||
require.Equal(t, "New Category", patchedChannel.DefaultCategoryName)
|
||||
}
|
||||
|
||||
@@ -271,6 +271,8 @@ channels/db/migrations/mysql/000136_create_attribute_view.down.sql
|
||||
channels/db/migrations/mysql/000136_create_attribute_view.up.sql
|
||||
channels/db/migrations/mysql/000137_update_attribute_view.down.sql
|
||||
channels/db/migrations/mysql/000137_update_attribute_view.up.sql
|
||||
channels/db/migrations/mysql/000138_add_default_category_name_to_channel.down.sql
|
||||
channels/db/migrations/mysql/000138_add_default_category_name_to_channel.up.sql
|
||||
channels/db/migrations/postgres/000001_create_teams.down.sql
|
||||
channels/db/migrations/postgres/000001_create_teams.up.sql
|
||||
channels/db/migrations/postgres/000002_create_team_members.down.sql
|
||||
@@ -543,3 +545,5 @@ channels/db/migrations/postgres/000136_create_attribute_view.down.sql
|
||||
channels/db/migrations/postgres/000136_create_attribute_view.up.sql
|
||||
channels/db/migrations/postgres/000137_update_attribute_view.down.sql
|
||||
channels/db/migrations/postgres/000137_update_attribute_view.up.sql
|
||||
channels/db/migrations/postgres/000138_add_default_category_name_to_channel.down.sql
|
||||
channels/db/migrations/postgres/000138_add_default_category_name_to_channel.up.sql
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE table_name = 'Channels'
|
||||
AND table_schema = DATABASE()
|
||||
AND column_name = 'DefaultCategoryName'
|
||||
),
|
||||
'ALTER TABLE Channels DROP COLUMN DefaultCategoryName;',
|
||||
'SELECT 1;'
|
||||
));
|
||||
|
||||
PREPARE removeColumnIfExists FROM @preparedStatement;
|
||||
EXECUTE removeColumnIfExists;
|
||||
DEALLOCATE PREPARE removeColumnIfExists;
|
||||
@@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
NOT EXISTS(
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = 'Channels'
|
||||
AND table_schema = DATABASE()
|
||||
AND column_name = 'DefaultCategoryName'
|
||||
),
|
||||
'ALTER TABLE Channels ADD COLUMN DefaultCategoryName varchar(64) NOT NULL DEFAULT "";',
|
||||
'SELECT 1;'
|
||||
));
|
||||
|
||||
PREPARE addColumnIfNotExists FROM @preparedStatement;
|
||||
EXECUTE addColumnIfNotExists;
|
||||
DEALLOCATE PREPARE addColumnIfNotExists;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE channels DROP COLUMN IF EXISTS DefaultCategoryName;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS DefaultCategoryName varchar(64) NOT NULL DEFAULT '';
|
||||
@@ -137,6 +137,7 @@ func channelSliceColumns(isSelect bool, prefix ...string) []string {
|
||||
p + "TotalMsgCountRoot",
|
||||
p + "LastRootPostAt",
|
||||
p + "BannerInfo",
|
||||
p + "DefaultCategoryName",
|
||||
}
|
||||
|
||||
if isSelect {
|
||||
@@ -172,6 +173,7 @@ func channelToSlice(channel *model.Channel) []any {
|
||||
channel.TotalMsgCountRoot,
|
||||
channel.LastRootPostAt,
|
||||
channel.BannerInfo,
|
||||
channel.DefaultCategoryName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -834,7 +836,8 @@ func (s SqlChannelStore) updateChannelT(transaction *sqlxTxWrapper, channel *mod
|
||||
Shared=:Shared,
|
||||
TotalMsgCountRoot=:TotalMsgCountRoot,
|
||||
LastRootPostAt=:LastRootPostAt,
|
||||
BannerInfo=:BannerInfo
|
||||
BannerInfo=:BannerInfo,
|
||||
DefaultCategoryName=:DefaultCategoryName
|
||||
WHERE Id=:Id`, channel)
|
||||
if err != nil {
|
||||
if IsUniqueConstraintError(err, []string{"Name", "channels_name_teamid_key"}) {
|
||||
|
||||
@@ -100,6 +100,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
|
||||
props["DisableRefetchingOnBrowserFocus"] = strconv.FormatBool(*c.ExperimentalSettings.DisableRefetchingOnBrowserFocus)
|
||||
props["DisableWakeUpReconnectHandler"] = strconv.FormatBool(*c.ExperimentalSettings.DisableWakeUpReconnectHandler)
|
||||
props["UsersStatusAndProfileFetchingPollIntervalMilliseconds"] = strconv.FormatInt(*c.ExperimentalSettings.UsersStatusAndProfileFetchingPollIntervalMilliseconds, 10)
|
||||
props["ExperimentalChannelCategorySorting"] = strconv.FormatBool(*c.ExperimentalSettings.ExperimentalChannelCategorySorting)
|
||||
|
||||
// Here we set the new option, but we also send the old FeatureFlag property for backwards compatibility on mobile < 2.27
|
||||
props["EnableCrossTeamSearch"] = strconv.FormatBool(*c.ServiceSettings.EnableCrossTeamSearch)
|
||||
|
||||
@@ -77,29 +77,30 @@ func (c ChannelBannerInfo) Value() (driver.Value, error) {
|
||||
}
|
||||
|
||||
type Channel struct {
|
||||
Id string `json:"id"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
DeleteAt int64 `json:"delete_at"`
|
||||
TeamId string `json:"team_id"`
|
||||
Type ChannelType `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Name string `json:"name"`
|
||||
Header string `json:"header"`
|
||||
Purpose string `json:"purpose"`
|
||||
LastPostAt int64 `json:"last_post_at"`
|
||||
TotalMsgCount int64 `json:"total_msg_count"`
|
||||
ExtraUpdateAt int64 `json:"extra_update_at"`
|
||||
CreatorId string `json:"creator_id"`
|
||||
SchemeId *string `json:"scheme_id"`
|
||||
Props map[string]any `json:"props"`
|
||||
GroupConstrained *bool `json:"group_constrained"`
|
||||
Shared *bool `json:"shared"`
|
||||
TotalMsgCountRoot int64 `json:"total_msg_count_root"`
|
||||
PolicyID *string `json:"policy_id"`
|
||||
LastRootPostAt int64 `json:"last_root_post_at"`
|
||||
BannerInfo *ChannelBannerInfo `json:"banner_info"`
|
||||
PolicyEnforced bool `json:"policy_enforced"`
|
||||
Id string `json:"id"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
DeleteAt int64 `json:"delete_at"`
|
||||
TeamId string `json:"team_id"`
|
||||
Type ChannelType `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Name string `json:"name"`
|
||||
Header string `json:"header"`
|
||||
Purpose string `json:"purpose"`
|
||||
LastPostAt int64 `json:"last_post_at"`
|
||||
TotalMsgCount int64 `json:"total_msg_count"`
|
||||
ExtraUpdateAt int64 `json:"extra_update_at"`
|
||||
CreatorId string `json:"creator_id"`
|
||||
SchemeId *string `json:"scheme_id"`
|
||||
Props map[string]any `json:"props"`
|
||||
GroupConstrained *bool `json:"group_constrained"`
|
||||
Shared *bool `json:"shared"`
|
||||
TotalMsgCountRoot int64 `json:"total_msg_count_root"`
|
||||
PolicyID *string `json:"policy_id"`
|
||||
LastRootPostAt int64 `json:"last_root_post_at"`
|
||||
BannerInfo *ChannelBannerInfo `json:"banner_info"`
|
||||
PolicyEnforced bool `json:"policy_enforced"`
|
||||
DefaultCategoryName string `json:"default_category_name"`
|
||||
}
|
||||
|
||||
func (o *Channel) Auditable() map[string]any {
|
||||
@@ -362,7 +363,7 @@ func (o *Channel) IsOpen() bool {
|
||||
|
||||
func (o *Channel) Patch(patch *ChannelPatch) {
|
||||
if patch.DisplayName != nil {
|
||||
o.DisplayName = *patch.DisplayName
|
||||
o.DisplayName = strings.TrimSpace(*patch.DisplayName)
|
||||
}
|
||||
|
||||
if patch.Name != nil {
|
||||
|
||||
@@ -1163,6 +1163,7 @@ type ExperimentalSettings struct {
|
||||
DisableWakeUpReconnectHandler *bool `access:"experimental_features"`
|
||||
UsersStatusAndProfileFetchingPollIntervalMilliseconds *int64 `access:"experimental_features"`
|
||||
YoutubeReferrerPolicy *bool `access:"experimental_features"`
|
||||
ExperimentalChannelCategorySorting *bool `access:"experimental_features"`
|
||||
}
|
||||
|
||||
func (s *ExperimentalSettings) SetDefaults() {
|
||||
@@ -1213,6 +1214,10 @@ func (s *ExperimentalSettings) SetDefaults() {
|
||||
if s.YoutubeReferrerPolicy == nil {
|
||||
s.YoutubeReferrerPolicy = NewPointer(false)
|
||||
}
|
||||
|
||||
if s.ExperimentalChannelCategorySorting == nil {
|
||||
s.ExperimentalChannelCategorySorting = NewPointer(false)
|
||||
}
|
||||
}
|
||||
|
||||
type AnalyticsSettings struct {
|
||||
|
||||
Ссылка в новой задаче
Block a user