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>
Этот коммит содержится в:
Devin Binnie
2025-06-11 14:29:36 -04:00
коммит произвёл GitHub
родитель c6a11763a8
Коммит 25a4839a9e
17 изменённых файлов: 289 добавлений и 30 удалений

Просмотреть файл

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