diff --git a/api/v4/source/channels.yaml b/api/v4/source/channels.yaml
index 01bd6e5fd9f..7a3794d8596 100644
--- a/api/v4/source/channels.yaml
+++ b/api/v4/source/channels.yaml
@@ -112,8 +112,12 @@
managed_category_name:
type: string
description: The name of the managed category to assign this channel to.
- Requires an Enterprise license and the `EnableManagedChannelCategories`
- config setting to be enabled.
+ Requires an Enterprise license and the `ManagedChannelCategories` feature flag
+ to be enabled.
+ default_category_name:
+ type: string
+ description: Default sidebar category name for members when joining this channel.
+ Requires `EnableChannelCategorySorting` to be enabled on the server.
description: Channel object to be created
required: true
responses:
@@ -683,7 +687,12 @@
type: string
description: The name of the managed category to assign this channel to.
Set to an empty string to clear. Requires an Enterprise license and
- the `EnableManagedChannelCategories` config setting to be enabled.
+ the `ManagedChannelCategories` feature flag to be enabled.
+ default_category_name:
+ type: string
+ description: Default sidebar category name for members when joining this channel.
+ Set to an empty string to clear. Requires `EnableChannelCategorySorting`
+ to be enabled on the server.
description: Channel patch object; include only the fields to update. At least
one field must be provided.
required: true
@@ -1195,8 +1204,8 @@
managed category assigned.
- Requires an Enterprise license and the `EnableManagedChannelCategories`
- config setting to be enabled.
+ Requires an Enterprise license and the `ManagedChannelCategories` feature flag
+ to be enabled.
##### Permissions
@@ -1222,8 +1231,10 @@
description: A map of channel ID to managed category name
"401":
$ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
+ "404":
+ description: Returned when the `ManagedChannelCategories` feature flag is disabled.
+ "501":
+ description: Returned when the server does not have an Enterprise license.
"/api/v4/teams/{team_id}/channels/search":
post:
tags:
diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/permissions/team_permissions_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/permissions/team_permissions_spec.ts
index a4b65f6315d..9b5a18dc55f 100644
--- a/e2e-tests/cypress/tests/integration/channels/enterprise/permissions/team_permissions_spec.ts
+++ b/e2e-tests/cypress/tests/integration/channels/enterprise/permissions/team_permissions_spec.ts
@@ -221,10 +221,10 @@ describe('Team Permissions', () => {
cy.get('.url-input-button').should('be.visible').and('not.be.disabled');
// * Verify user can edit channel purpose
- cy.get('#channel_settings_purpose_textbox').should('be.visible').and('not.be.disabled');
+ cy.get('#channel_settings_purpose_textbox').scrollIntoView().should('be.visible').and('not.be.disabled');
// * Verify user can edit channel header
- cy.get('#channel_settings_header_textbox').should('be.visible').and('not.be.disabled');
+ cy.get('#channel_settings_header_textbox').scrollIntoView().should('be.visible').and('not.be.disabled');
// # Close the modal
cy.get('.GenericModal .modal-header button[aria-label="Close"]').click();
diff --git a/e2e-tests/cypress/tests/support/ui_commands.ts b/e2e-tests/cypress/tests/support/ui_commands.ts
index 65876dea07e..9424bcfcf8d 100644
--- a/e2e-tests/cypress/tests/support/ui_commands.ts
+++ b/e2e-tests/cypress/tests/support/ui_commands.ts
@@ -474,6 +474,7 @@ function updateChannelHeader(text: string) {
// # Edit channel header in the modal
cy.get('#channel_settings_header_textbox').
+ scrollIntoView().
should('be.visible').
clear().
type(text);
diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts
index 744458089c3..82f488ad742 100644
--- a/e2e-tests/playwright/lib/src/server/default_config.ts
+++ b/e2e-tests/playwright/lib/src/server/default_config.ts
@@ -239,6 +239,7 @@ const defaultServerConfig: AdminConfig = {
LockTeammateNameDisplay: false,
ExperimentalPrimaryTeam: '',
ExperimentalDefaultChannels: [],
+ EnableChannelCategorySorting: true,
},
ClientRequirements: {
AndroidLatestVersion: '',
@@ -618,7 +619,6 @@ const defaultServerConfig: AdminConfig = {
DisableWakeUpReconnectHandler: false,
UsersStatusAndProfileFetchingPollIntervalMilliseconds: 3000,
YoutubeReferrerPolicy: false,
- ExperimentalChannelCategorySorting: false,
EnableWatermark: false,
},
AnalyticsSettings: {
@@ -789,6 +789,7 @@ const defaultServerConfig: AdminConfig = {
ClassificationMarkings: true,
IntegratedBoards: false,
CJKSearch: false,
+ ManagedChannelCategories: false,
},
ImportSettings: {
Directory: './import',
diff --git a/e2e-tests/playwright/specs/functional/channels/categories/default_categories.spec.ts b/e2e-tests/playwright/specs/functional/channels/categories/default_categories.spec.ts
new file mode 100644
index 00000000000..549a2450b53
--- /dev/null
+++ b/e2e-tests/playwright/specs/functional/channels/categories/default_categories.spec.ts
@@ -0,0 +1,246 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {expect, test} from '@mattermost/playwright-lib';
+
+async function skipIfNoEnterpriseLicense(adminClient: any) {
+ const license = await adminClient.getClientLicenseOld();
+ test.skip(license.IsLicensed !== 'true', 'Skipping test - server does not have an enterprise license');
+}
+
+async function enableChannelCategorySorting(adminClient: any) {
+ await adminClient.patchConfig({
+ TeamSettings: {
+ EnableChannelCategorySorting: true,
+ },
+ });
+}
+
+async function disableChannelCategorySorting(adminClient: any) {
+ await adminClient.patchConfig({
+ TeamSettings: {
+ EnableChannelCategorySorting: false,
+ },
+ });
+}
+
+test.describe('Channel Category Sorting', () => {
+ /**
+ * @objective Verify that the default category selector is not visible in channel settings when channel category sorting is disabled.
+ */
+ test(
+ 'default category selector is not visible when channel category sorting is disabled',
+ {tag: '@channel_category_sorting'},
+ async ({pw}) => {
+ // # Initialize setup and disable channel category sorting
+ const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
+ await skipIfNoEnterpriseLicense(adminClient);
+ await disableChannelCategorySorting(adminClient);
+ await adminClient.addToTeam(team.id, adminUser.id);
+
+ // # Log in and open channel settings
+ const {channelsPage} = await pw.testBrowser.login(adminUser);
+ await channelsPage.goto(team.name, 'town-square');
+ await channelsPage.toBeVisible();
+
+ const channelSettingsModal = await channelsPage.openChannelSettings();
+ await channelSettingsModal.openInfoTab();
+
+ // * Verify the default category selector is not rendered
+ const defaultCategorySelector = channelSettingsModal.container
+ .locator('.CategorySelector')
+ .filter({hasText: 'Choose a default category (optional)'});
+ await expect(defaultCategorySelector).toHaveCount(0);
+
+ await channelSettingsModal.close();
+ },
+ );
+
+ /**
+ * @objective Verify that a default category can be assigned when creating a new channel via the new channel modal.
+ */
+ test(
+ 'default category can be assigned when creating a new channel',
+ {tag: '@channel_category_sorting'},
+ async ({pw}) => {
+ // # Initialize setup and enable channel category sorting
+ const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
+ await skipIfNoEnterpriseLicense(adminClient);
+ await enableChannelCategorySorting(adminClient);
+ await adminClient.addToTeam(team.id, adminUser.id);
+
+ // # Log in and open the new channel modal
+ const {page, channelsPage} = await pw.testBrowser.login(adminUser);
+ await channelsPage.goto(team.name, 'town-square');
+ await channelsPage.toBeVisible();
+
+ const newChannelModal = await channelsPage.openNewChannelModal();
+ const displayName = `New Default Cat ${Date.now()}`;
+ await newChannelModal.fillDisplayName(displayName);
+
+ // # Locate the default category selector and select a new category
+ const defaultCategorySection = newChannelModal.container.locator('.CategorySelector').first();
+ await expect(defaultCategorySection).toContainText('Choose a default category (optional)');
+
+ const defaultCategoryControl = defaultCategorySection.locator('.CategorySelector__control');
+ await defaultCategoryControl.click();
+
+ const input = defaultCategorySection.getByRole('combobox');
+ await input.fill('Flight Ops');
+
+ const createOption = page.getByRole('option', {name: 'Create new category: Flight Ops'});
+ await expect(createOption).toBeVisible();
+ await createOption.click();
+
+ await newChannelModal.create();
+ await channelsPage.toBeVisible();
+ await pw.wait(pw.duration.two_sec);
+
+ // * Verify the new category appears in the sidebar
+ const sidebar = channelsPage.sidebarLeft.container;
+ await expect(sidebar.getByText('Flight Ops')).toBeVisible();
+ },
+ );
+
+ /**
+ * @objective Verify that a Channel Admin can assign a default category to an existing channel via the channel
+ * settings modal, and the category appears in the sidebar with the channel under it for the patching user.
+ */
+ test('default category can be assigned via channel settings', {tag: '@channel_category_sorting'}, async ({pw}) => {
+ // # Initialize setup with admin user and enterprise license
+ const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
+ await skipIfNoEnterpriseLicense(adminClient);
+ await enableChannelCategorySorting(adminClient);
+ await adminClient.addToTeam(team.id, adminUser.id);
+
+ // # Log in and navigate to town-square
+ const {page, channelsPage} = await pw.testBrowser.login(adminUser);
+ await channelsPage.goto(team.name, 'town-square');
+ await channelsPage.toBeVisible();
+
+ // # Create a new channel
+ const channelName = `default-assign-${Date.now()}`;
+ await channelsPage.newChannel(channelName, 'O');
+ await channelsPage.toBeVisible();
+
+ // # Open channel settings and navigate to info tab
+ const channelSettingsModal = await channelsPage.openChannelSettings();
+ await channelSettingsModal.openInfoTab();
+
+ // * Verify the default category selector is visible
+ const defaultCategorySection = channelSettingsModal.container.locator('.CategorySelector').first();
+ await expect(defaultCategorySection).toContainText('Choose a default category (optional)');
+
+ // # Click the selector, type a new category name, and select "Create new category"
+ const defaultCategoryControl = defaultCategorySection.locator('.CategorySelector__control');
+ await defaultCategoryControl.click();
+
+ const input = defaultCategorySection.getByRole('combobox');
+ await input.fill('Operations');
+
+ const createOption = page.getByRole('option', {name: 'Create new category: Operations'});
+ await expect(createOption).toBeVisible();
+ await createOption.click();
+
+ // # Save and close
+ await channelSettingsModal.save();
+ await pw.wait(pw.duration.two_sec);
+ await channelSettingsModal.close();
+
+ // * Verify the default category appears in the sidebar with the channel under it
+ const sidebar = channelsPage.sidebarLeft.container;
+ await expect(sidebar.getByText('Operations')).toBeVisible();
+
+ const operationsSection = sidebar.locator('.SidebarChannelGroup').filter({hasText: 'Operations'});
+ await expect(operationsSection.locator(`#sidebarItem_${channelName}`)).toBeVisible();
+ });
+
+ /**
+ * @objective Verify that a Channel Admin can remove a default category from a channel via the channel settings
+ * modal, and reopening the modal shows the field cleared.
+ */
+ test('default category can be removed via channel settings', {tag: '@channel_category_sorting'}, async ({pw}) => {
+ // # Initialize setup and create a channel with a default category set
+ const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
+ await skipIfNoEnterpriseLicense(adminClient);
+ await enableChannelCategorySorting(adminClient);
+ await adminClient.addToTeam(team.id, adminUser.id);
+
+ const channel = await adminClient.createChannel({
+ team_id: team.id,
+ name: `default-cat-remove-${Date.now()}`,
+ display_name: `Default remove ${Date.now()}`,
+ type: 'O',
+ });
+ await adminClient.patchChannel(channel.id, {default_category_name: 'Removable'});
+ await adminClient.addToChannel(adminUser.id, channel.id);
+
+ // # Log in and navigate to the channel
+ const {channelsPage} = await pw.testBrowser.login(adminUser);
+ await channelsPage.goto(team.name, channel.name);
+ await channelsPage.toBeVisible();
+
+ // * Verify the default category is visible in the sidebar (admin was added after the patch set it)
+ const sidebar = channelsPage.sidebarLeft.container;
+ await expect(sidebar.getByText('Removable')).toBeVisible();
+
+ // # Open channel settings and click the clear button on the default category selector
+ let channelSettingsModal = await channelsPage.openChannelSettings();
+ await channelSettingsModal.openInfoTab();
+
+ const defaultCategorySection = channelSettingsModal.container.locator('.CategorySelector').first();
+ await expect(defaultCategorySection).toContainText('Removable');
+
+ const clearButton = defaultCategorySection.locator('.CategorySelector__clear-indicator');
+ await expect(clearButton).toBeVisible();
+ await clearButton.click();
+ await pw.wait(pw.duration.half_sec);
+
+ // * Verify the clear button is gone (no value selected)
+ await expect(clearButton).not.toBeVisible();
+
+ // # Save and close
+ await channelSettingsModal.save();
+ await pw.wait(pw.duration.two_sec);
+ await channelSettingsModal.close();
+
+ // # Reopen channel settings to verify the cleared value persisted
+ channelSettingsModal = await channelsPage.openChannelSettings();
+ await channelSettingsModal.openInfoTab();
+
+ // * Verify the default category selector shows the placeholder (value is cleared)
+ const reopenedDefaultCategorySection = channelSettingsModal.container.locator('.CategorySelector').first();
+ await expect(reopenedDefaultCategorySection).toContainText('Choose a default category (optional)');
+
+ await channelSettingsModal.close();
+ });
+
+ /**
+ * @objective Verify that the Channel category sorting setting is available in the System Console
+ * under Site Configuration > Users and Teams.
+ */
+ test(
+ 'Channel category sorting setting is available in System Console',
+ {tag: '@channel_category_sorting'},
+ async ({pw}) => {
+ // # Initialize setup
+ const {adminUser, adminClient} = await pw.initSetup({withDefaultProfileImage: false});
+ await skipIfNoEnterpriseLicense(adminClient);
+
+ // # Log in and navigate to the System Console
+ const {systemConsolePage} = await pw.testBrowser.login(adminUser);
+ await systemConsolePage.goto();
+ await systemConsolePage.toBeVisible();
+
+ // # Navigate to Users and Teams
+ await systemConsolePage.sidebar.siteConfiguration.usersAndTeams.click();
+ await systemConsolePage.usersAndTeams.toBeVisible();
+
+ // * Verify the setting is visible
+ const setting = systemConsolePage.usersAndTeams.container.getByTestId(
+ 'TeamSettings.EnableChannelCategorySortingtrue',
+ );
+ await expect(setting).toBeVisible();
+ },
+ );
+});
diff --git a/e2e-tests/playwright/specs/functional/channels/managed_categories/managed_categories.spec.ts b/e2e-tests/playwright/specs/functional/channels/categories/managed_categories.spec.ts
similarity index 96%
rename from e2e-tests/playwright/specs/functional/channels/managed_categories/managed_categories.spec.ts
rename to e2e-tests/playwright/specs/functional/channels/categories/managed_categories.spec.ts
index 0b11bf8e1da..41c9da4b8ce 100644
--- a/e2e-tests/playwright/specs/functional/channels/managed_categories/managed_categories.spec.ts
+++ b/e2e-tests/playwright/specs/functional/channels/categories/managed_categories.spec.ts
@@ -49,6 +49,8 @@ test.describe('Managed Channel Categories', () => {
'Channel Admin can assign a managed category via channel settings',
{tag: '@managed_categories'},
async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup with admin user and enterprise license
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -104,6 +106,8 @@ test.describe('Managed Channel Categories', () => {
'Channel Admin can remove a managed category via channel settings',
{tag: '@managed_categories'},
async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -154,6 +158,8 @@ test.describe('Managed Channel Categories', () => {
'managed category selector is not visible when feature is disabled',
{tag: '@managed_categories'},
async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', false);
+
// # Initialize setup and disable managed categories
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -180,6 +186,8 @@ test.describe('Managed Channel Categories', () => {
* @objective Verify that a managed category can be assigned to a channel during creation via the new channel modal.
*/
test('managed category can be assigned when creating a new channel', {tag: '@managed_categories'}, async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup and enable managed categories
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -224,6 +232,8 @@ test.describe('Managed Channel Categories', () => {
'managed categories appear at the top of the sidebar above personal categories',
{tag: '@managed_categories'},
async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -261,6 +271,8 @@ test.describe('Managed Channel Categories', () => {
'managed category is only visible when user is a member of a channel in it',
{tag: '@managed_categories'},
async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup and create a channel with a managed category (without adding the user)
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -284,6 +296,8 @@ test.describe('Managed Channel Categories', () => {
* @objective Verify that channels within a managed category are sorted alphabetically by display name.
*/
test('managed categories sort channels alphabetically', {tag: '@managed_categories'}, async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup and create two channels with the same managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -338,6 +352,8 @@ test.describe('Managed Channel Categories', () => {
* @objective Verify that the favorite button is disabled for channels in managed categories.
*/
test('channels in managed categories cannot be favorited', {tag: '@managed_categories'}, async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -362,6 +378,8 @@ test.describe('Managed Channel Categories', () => {
* @objective Verify that managed category headers do not show a context menu on right-click.
*/
test('managed categories do not show a context menu', {tag: '@managed_categories'}, async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -396,6 +414,8 @@ test.describe('Managed Channel Categories', () => {
'channel context menu shows favorite as disabled in managed category',
{tag: '@managed_categories'},
async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -437,6 +457,8 @@ test.describe('Managed Channel Categories', () => {
'Move To is disabled for non-admin users on channels in managed categories',
{tag: '@managed_categories'},
async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -479,6 +501,8 @@ test.describe('Managed Channel Categories', () => {
'assigning the same category name to multiple channels groups them together',
{tag: '@managed_categories'},
async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup and create two channels with the same managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -526,6 +550,8 @@ test.describe('Managed Channel Categories', () => {
'managed category appears in real-time when admin assigns a channel to it',
{tag: '@managed_categories'},
async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup and create a channel without a managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -576,6 +602,8 @@ test.describe('Managed Channel Categories', () => {
'Enable Managed Channel Categories setting is available in System Console',
{tag: '@managed_categories'},
async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup
const {adminUser, adminClient} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
@@ -604,6 +632,8 @@ test.describe('Managed Channel Categories', () => {
'non-channel-admin sees the managed category selector as disabled',
{tag: '@managed_categories'},
async ({pw}) => {
+ await pw.skipIfFeatureFlagNotSet('ManagedChannelCategories', true);
+
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go
index c7fc2ad9d54..d55b316a81d 100644
--- a/server/channels/api4/channel.go
+++ b/server/channels/api4/channel.go
@@ -37,7 +37,9 @@ func (api *API) InitChannel() {
api.BaseRoutes.ChannelsForTeam.Handle("/search", api.APISessionRequiredDisableWhenBusy(searchChannelsForTeam)).Methods(http.MethodPost)
api.BaseRoutes.ChannelsForTeam.Handle("/autocomplete", api.APISessionRequired(autocompleteChannelsForTeam)).Methods(http.MethodGet)
api.BaseRoutes.ChannelsForTeam.Handle("/search_autocomplete", api.APISessionRequired(autocompleteChannelsForTeamForSearch)).Methods(http.MethodGet)
- api.BaseRoutes.ChannelsForTeam.Handle("/managed_categories", api.APISessionRequired(getManagedCategories)).Methods(http.MethodGet)
+ if api.srv.Config().FeatureFlags.ManagedChannelCategories {
+ api.BaseRoutes.ChannelsForTeam.Handle("/managed_categories", api.APISessionRequired(getManagedCategories)).Methods(http.MethodGet)
+ }
api.BaseRoutes.User.Handle("/teams/{team_id:[A-Za-z0-9]+}/channels", api.APISessionRequired(getChannelsForTeamForUser)).Methods(http.MethodGet)
api.BaseRoutes.User.Handle("/channels", api.APISessionRequired(getChannelsForUser)).Methods(http.MethodGet)
@@ -352,7 +354,7 @@ func patchChannel(c *Context, w http.ResponseWriter, r *http.Request) {
model.AddEventParameterAuditableToAuditRec(auditRec, "channel", patch)
auditRec.AddEventPriorState(oldChannel)
- updatingProperties := patch.DisplayName != nil || patch.Name != nil || patch.Header != nil || patch.Purpose != nil || patch.GroupConstrained != nil
+ updatingProperties := patch.DisplayName != nil || patch.Name != nil || patch.Header != nil || patch.Purpose != nil || patch.GroupConstrained != nil || patch.DefaultCategoryName != nil
updatingAutoTranslation := patch.AutoTranslation != nil
updatingManagedCategory := patch.ManagedCategoryName != nil
@@ -401,7 +403,7 @@ func patchChannel(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = model.NewAppError("patchChannel", "api.channel.patch_update_channel.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
- if (patch.Name != nil && *patch.Name != oldChannel.Name) || (patch.DisplayName != nil && *patch.DisplayName != oldChannel.DisplayName) || (patch.Purpose != nil && *patch.Purpose != oldChannel.Purpose) {
+ if (patch.Name != nil && *patch.Name != oldChannel.Name) || (patch.DisplayName != nil && *patch.DisplayName != oldChannel.DisplayName) || (patch.Purpose != nil && *patch.Purpose != oldChannel.Purpose) || patch.DefaultCategoryName != nil {
c.Err = model.NewAppError("patchChannel", "api.channel.patch_update_channel.update_direct_or_group_messages_not_allowed.app_error", nil, "", http.StatusBadRequest)
return
}
@@ -431,7 +433,7 @@ func patchChannel(c *Context, w http.ResponseWriter, r *http.Request) {
}
if updatingManagedCategory {
- if model.MinimumEnterpriseLicense(c.App.Channels().License()) && *c.App.Config().TeamSettings.EnableManagedChannelCategories {
+ if model.MinimumEnterpriseLicense(c.App.Channels().License()) && c.App.Config().FeatureFlags.ManagedChannelCategories {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelRoles); !ok {
c.Err = model.NewAppError("patchChannel", "api.channel.patch_update_channel.cannot_update_managed_category.app_error", nil, "", http.StatusForbidden)
return
@@ -455,7 +457,7 @@ func patchChannel(c *Context, w http.ResponseWriter, r *http.Request) {
}
if updatingManagedCategory {
- if !model.MinimumEnterpriseLicense(c.App.Channels().License()) || !*c.App.Config().TeamSettings.EnableManagedChannelCategories {
+ if !model.MinimumEnterpriseLicense(c.App.Channels().License()) || !c.App.Config().FeatureFlags.ManagedChannelCategories {
c.Logger.Info("Managed category update ignored: feature not available")
} else {
name := *patch.ManagedCategoryName
@@ -2964,11 +2966,6 @@ func getManagedCategories(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- if !*c.App.Config().TeamSettings.EnableManagedChannelCategories {
- c.Err = model.NewAppError("Api4.getManagedCategories", "api.managed_category.feature_not_available.app_error", nil, "", http.StatusForbidden)
- return
- }
-
mappings, appErr := c.App.GetVisibleManagedCategoryMappings(c.AppContext, teamID)
if appErr != nil {
c.Err = appErr
diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go
index 252aed001df..f62be82b407 100644
--- a/server/channels/api4/channel_test.go
+++ b/server/channels/api4/channel_test.go
@@ -335,7 +335,13 @@ func TestCreateChannel(t *testing.T) {
func TestCreateChannelManagedCategory(t *testing.T) {
mainHelper.Parallel(t)
- th := Setup(t).InitBasic(t)
+ th := SetupConfig(t, func(cfg *model.Config) {
+ cfg.FeatureFlags.ManagedChannelCategories = true
+ }).InitBasic(t)
+ th.ConfigStore.SetReadOnlyFF(false)
+ t.Cleanup(func() {
+ th.ConfigStore.SetReadOnlyFF(true)
+ })
client := th.Client
team := th.BasicTeam
@@ -358,7 +364,7 @@ func TestCreateChannelManagedCategory(t *testing.T) {
t.Run("should ignore managed category when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
- th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.EnableManagedChannelCategories = false })
+ th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.ManagedChannelCategories = false })
defer func() {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
@@ -379,7 +385,7 @@ func TestCreateChannelManagedCategory(t *testing.T) {
t.Run("should set managed category when feature is enabled with license", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
- th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.EnableManagedChannelCategories = true })
+ th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.ManagedChannelCategories = true })
defer func() {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
@@ -993,6 +999,76 @@ func TestPatchChannel(t *testing.T) {
CheckBadRequestStatus(t, resp)
})
+ t.Run("Should block changes to default_category_name for group messages", func(t *testing.T) {
+ user1 := th.CreateUser(t)
+ user2 := th.CreateUser(t)
+ user3 := th.CreateUser(t)
+
+ _, err := client.Logout(context.Background())
+ require.NoError(t, err)
+ _, _, err = client.Login(context.Background(), user1.Email, user1.Password)
+ require.NoError(t, err)
+
+ groupChannel, _, err := client.CreateGroupChannel(context.Background(), []string{user1.Id, user2.Id, user3.Id})
+ require.NoError(t, err)
+
+ categoryName := "Operations"
+ patch := &model.ChannelPatch{DefaultCategoryName: &categoryName}
+ _, resp, err := client.PatchChannel(context.Background(), groupChannel.Id, patch)
+ require.Error(t, err)
+ CheckBadRequestStatus(t, resp)
+ })
+
+ t.Run("Should block changes to default_category_name for direct messages", func(t *testing.T) {
+ user1 := th.CreateUser(t)
+ user2 := th.CreateUser(t)
+
+ _, err := client.Logout(context.Background())
+ require.NoError(t, err)
+ _, _, err = client.Login(context.Background(), user1.Email, user1.Password)
+ require.NoError(t, err)
+
+ directChannel, _, err := client.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
+ require.NoError(t, err)
+
+ categoryName := "Operations"
+ patch := &model.ChannelPatch{DefaultCategoryName: &categoryName}
+ _, resp, err := client.PatchChannel(context.Background(), directChannel.Id, patch)
+ require.Error(t, err)
+ CheckBadRequestStatus(t, resp)
+ })
+
+ t.Run("Should be able to patch default_category_name on an open channel", func(t *testing.T) {
+ _, err := client.Logout(context.Background())
+ require.NoError(t, err)
+ th.LoginBasic(t)
+
+ channel := &model.Channel{
+ DisplayName: GenerateTestChannelName(),
+ Name: GenerateTestChannelName(),
+ Type: model.ChannelTypeOpen,
+ TeamId: team.Id,
+ }
+ channel, _, err = client.CreateChannel(context.Background(), channel)
+ require.NoError(t, err)
+
+ categoryName := "Operations"
+ patch := &model.ChannelPatch{DefaultCategoryName: &categoryName}
+ patched, _, err := client.PatchChannel(context.Background(), channel.Id, patch)
+ require.NoError(t, err)
+ require.Equal(t, categoryName, patched.DefaultCategoryName)
+
+ fetched, _, err := client.GetChannel(context.Background(), channel.Id)
+ require.NoError(t, err)
+ require.Equal(t, categoryName, fetched.DefaultCategoryName)
+
+ emptyName := ""
+ clearPatch := &model.ChannelPatch{DefaultCategoryName: &emptyName}
+ cleared, _, err := client.PatchChannel(context.Background(), channel.Id, clearPatch)
+ require.NoError(t, err)
+ require.Equal(t, "", cleared.DefaultCategoryName)
+ })
+
t.Run("Should not be able to configure channel banner without a license", func(t *testing.T) {
_, err := client.Logout(context.Background())
require.NoError(t, err)
@@ -7582,7 +7658,9 @@ func TestSetChannelMembers(t *testing.T) {
func TestGetManagedCategories(t *testing.T) {
mainHelper.Parallel(t)
- th := Setup(t).InitBasic(t)
+ th := SetupConfig(t, func(cfg *model.Config) {
+ cfg.FeatureFlags.ManagedChannelCategories = true
+ }).InitBasic(t)
client := th.Client
t.Run("should return 501 without enterprise license", func(t *testing.T) {
@@ -7591,26 +7669,12 @@ func TestGetManagedCategories(t *testing.T) {
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
- t.Run("should return 403 when feature is disabled", func(t *testing.T) {
- th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
- defer func() {
- appErr := th.App.Srv().RemoveLicense()
- require.Nil(t, appErr)
- }()
- th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.EnableManagedChannelCategories = false })
-
- resp, err := client.DoAPIGet(context.Background(), fmt.Sprintf("/teams/%s/channels/managed_categories", th.BasicTeam.Id), "")
- require.Error(t, err)
- require.Equal(t, http.StatusForbidden, resp.StatusCode)
- })
-
t.Run("should return empty map when no managed categories exist", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer func() {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
}()
- th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.EnableManagedChannelCategories = true })
resp, err := client.DoAPIGet(context.Background(), fmt.Sprintf("/teams/%s/channels/managed_categories", th.BasicTeam.Id), "")
require.NoError(t, err)
@@ -7627,7 +7691,6 @@ func TestGetManagedCategories(t *testing.T) {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
}()
- th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.EnableManagedChannelCategories = true })
appErr := th.App.SetChannelManagedCategory(th.Context, th.BasicChannel.Id, "Operations")
require.Nil(t, appErr)
@@ -7645,17 +7708,40 @@ func TestGetManagedCategories(t *testing.T) {
})
}
-func TestPatchChannelManagedCategory(t *testing.T) {
+func TestGetManagedCategoriesFeatureFlagDisabled(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
+
+ t.Run("route is not registered when feature flag is off at startup", func(t *testing.T) {
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+ defer func() {
+ appErr := th.App.Srv().RemoveLicense()
+ require.Nil(t, appErr)
+ }()
+
+ resp, err := th.Client.DoAPIGet(context.Background(), fmt.Sprintf("/teams/%s/channels/managed_categories", th.BasicTeam.Id), "")
+ require.Error(t, err)
+ require.Equal(t, http.StatusNotFound, resp.StatusCode)
+ })
+}
+
+func TestPatchChannelManagedCategory(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := SetupConfig(t, func(cfg *model.Config) {
+ cfg.FeatureFlags.ManagedChannelCategories = true
+ }).InitBasic(t)
+ th.ConfigStore.SetReadOnlyFF(false)
+ t.Cleanup(func() {
+ th.ConfigStore.SetReadOnlyFF(true)
+ })
client := th.Client
enableManagedCategories := func() {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
- th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.EnableManagedChannelCategories = true })
+ th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.ManagedChannelCategories = true })
}
disableManagedCategories := func() {
- th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.EnableManagedChannelCategories = false })
+ th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.ManagedChannelCategories = false })
}
removeLicense := func() {
appErr := th.App.Srv().RemoveLicense()
diff --git a/server/channels/api4/properties.go b/server/channels/api4/properties.go
index 0b70e37a71d..5d647bb792b 100644
--- a/server/channels/api4/properties.go
+++ b/server/channels/api4/properties.go
@@ -17,10 +17,11 @@ import (
const maxPropertyValuePatchItems = 50
func (api *API) InitProperties() {
- api.BaseRoutes.PropertyFields.Handle("", api.APISessionRequired(getPropertyFields)).Methods(http.MethodGet)
- api.BaseRoutes.PropertyValues.Handle("", api.APISessionRequired(getPropertyValues)).Methods(http.MethodGet)
- api.BaseRoutes.PropertySystemValues.Handle("", api.APISessionRequired(getSystemPropertyValues)).Methods(http.MethodGet)
- if api.srv.Config().FeatureFlags.IntegratedBoards || api.srv.Config().FeatureFlags.ClassificationMarkings {
+ if api.srv.Config().FeatureFlags.IntegratedBoards || api.srv.Config().FeatureFlags.ManagedChannelCategories || api.srv.Config().FeatureFlags.ClassificationMarkings {
+ api.BaseRoutes.PropertyFields.Handle("", api.APISessionRequired(getPropertyFields)).Methods(http.MethodGet)
+ api.BaseRoutes.PropertyValues.Handle("", api.APISessionRequired(getPropertyValues)).Methods(http.MethodGet)
+ api.BaseRoutes.PropertySystemValues.Handle("", api.APISessionRequired(getSystemPropertyValues)).Methods(http.MethodGet)
+
api.BaseRoutes.PropertyFields.Handle("", api.APISessionRequired(createPropertyField)).Methods(http.MethodPost)
api.BaseRoutes.PropertyField.Handle("", api.APISessionRequired(patchPropertyField)).Methods(http.MethodPatch)
api.BaseRoutes.PropertyField.Handle("", api.APISessionRequired(deletePropertyField)).Methods(http.MethodDelete)
diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go
index 2afdfb59063..d4f24e97b77 100644
--- a/server/channels/app/channel.go
+++ b/server/channels/app/channel.go
@@ -229,9 +229,9 @@ func (a *App) RenameChannel(rctx request.CTX, channel *model.Channel, newChannel
}
func (a *App) CreateChannel(rctx request.CTX, channel *model.Channel, addMember bool) (*model.Channel, *model.AppError) {
- a.handleChannelCategoryName(channel)
-
channel.DisplayName = strings.TrimSpace(channel.DisplayName)
+ channel.DefaultCategoryName = strings.TrimSpace(channel.DefaultCategoryName)
+ channel.ManagedCategoryName = strings.TrimSpace(channel.ManagedCategoryName)
sc, nErr := a.Srv().Store().Channel().Save(rctx, channel, *a.Config().TeamSettings.MaxChannelsPerTeam)
if nErr != nil {
var invErr *store.ErrInvalidInput
@@ -304,7 +304,7 @@ func (a *App) CreateChannel(rctx request.CTX, channel *model.Channel, addMember
}
if channel.ManagedCategoryName != "" {
- if !model.MinimumEnterpriseLicense(a.Channels().License()) || !model.SafeDereference(a.Config().TeamSettings.EnableManagedChannelCategories) {
+ if !model.MinimumEnterpriseLicense(a.Channels().License()) || !a.Config().FeatureFlags.ManagedChannelCategories {
rctx.Logger().Warn("Managed category update ignored: feature not available")
sc.ManagedCategoryName = ""
} else {
@@ -985,7 +985,6 @@ func (a *App) PatchChannel(rctx request.CTX, channel *model.Channel, patch *mode
oldChannelAutotranslation := channel.AutoTranslation
channel.Patch(patch)
- a.handleChannelCategoryName(channel)
channel, err = a.UpdateChannel(rctx, channel)
if err != nil {
return nil, err
@@ -4325,17 +4324,9 @@ func (a *App) GetRecommendedPublicChannelsForUser(rctx request.CTX, userID, team
return recommended, 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(rctx request.CTX, userID string, channel *model.Channel) {
// Add channel to default category if specified
- if channel.DefaultCategoryName != "" && *a.Config().ExperimentalSettings.ExperimentalChannelCategorySorting {
+ if channel.DefaultCategoryName != "" && *a.Config().TeamSettings.EnableChannelCategorySorting {
// Get user's categories for this team
categories, err := a.GetSidebarCategoriesForTeamForUser(rctx, userID, channel.TeamId)
if err != nil {
@@ -4351,6 +4342,21 @@ func (a *App) addChannelToDefaultCategory(rctx request.CTX, userID string, chann
}
}
+ // Find the original category if the channel is already in a category
+ var originalCategory *model.SidebarCategoryWithChannels
+ for _, category := range categories.Categories {
+ if category.Type == model.SidebarCategoryCustom && category.Channels != nil && slices.Contains(category.Channels, channel.Id) {
+ originalCategory = category
+ break
+ }
+ }
+
+ var categoriesToUpdate []*model.SidebarCategoryWithChannels
+ if originalCategory != nil {
+ originalCategory.Channels = slices.Delete(originalCategory.Channels, slices.Index(originalCategory.Channels, channel.Id), 1)
+ categoriesToUpdate = append(categoriesToUpdate, originalCategory)
+ }
+
if targetCategory == nil {
// Create new category if it doesn't exist
targetCategory = &model.SidebarCategoryWithChannels{
@@ -4368,12 +4374,14 @@ func (a *App) addChannelToDefaultCategory(rctx request.CTX, userID string, chann
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(rctx, 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))
- }
+ categoriesToUpdate = append(categoriesToUpdate, targetCategory)
+ }
+
+ // Add channel to existing category
+ _, err = a.UpdateSidebarCategories(rctx, userID, channel.TeamId, categoriesToUpdate)
+ 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))
}
}
}
diff --git a/server/channels/app/channel_test.go b/server/channels/app/channel_test.go
index a18cfc49001..167b52b80d1 100644
--- a/server/channels/app/channel_test.go
+++ b/server/channels/app/channel_test.go
@@ -3813,17 +3813,18 @@ func TestPatchChannel(t *testing.T) {
func TestCreateChannelWithCategorySorting(t *testing.T) {
th := Setup(t).InitBasic(t)
- // Enable ExperimentalChannelCategorySorting
+ // Enable channel category sorting (default category names)
th.App.UpdateConfig(func(cfg *model.Config) {
- *cfg.ExperimentalSettings.ExperimentalChannelCategorySorting = true
+ *cfg.TeamSettings.EnableChannelCategorySorting = 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,
+ DisplayName: " Channel Name ",
+ DefaultCategoryName: " Category ",
+ Name: "name1",
+ Type: model.ChannelTypeOpen,
+ TeamId: th.BasicTeam.Id,
}
channel, appErr := th.App.CreateChannelWithUser(th.Context, channel, th.BasicUser.Id)
@@ -3866,10 +3867,11 @@ func TestCreateChannelWithCategorySorting(t *testing.T) {
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,
+ DisplayName: "Channel Name",
+ DefaultCategoryName: "Category2",
+ Name: "name2",
+ Type: model.ChannelTypeOpen,
+ TeamId: th.BasicTeam.Id,
}
channel, appErr := th.App.CreateChannel(th.Context, channel, false)
@@ -3877,9 +3879,9 @@ func TestCreateChannelWithCategorySorting(t *testing.T) {
require.Equal(t, "Channel Name", channel.DisplayName)
require.Equal(t, "Category2", channel.DefaultCategoryName)
- // Disable ExperimentalChannelCategorySorting
+ // Disable channel category sorting
th.App.UpdateConfig(func(cfg *model.Config) {
- *cfg.ExperimentalSettings.ExperimentalChannelCategorySorting = false
+ *cfg.TeamSettings.EnableChannelCategorySorting = false
})
// Add user to channel
@@ -3904,9 +3906,9 @@ func TestCreateChannelWithCategorySorting(t *testing.T) {
func TestPatchChannelWithCategorySorting(t *testing.T) {
th := Setup(t).InitBasic(t)
- // Enable ExperimentalChannelCategorySorting
+ // Enable channel category sorting
th.App.UpdateConfig(func(cfg *model.Config) {
- *cfg.ExperimentalSettings.ExperimentalChannelCategorySorting = true
+ *cfg.TeamSettings.EnableChannelCategorySorting = true
})
// Create initial channel
@@ -3919,9 +3921,9 @@ func TestPatchChannelWithCategorySorting(t *testing.T) {
_, 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 "),
+ DisplayName: model.NewPointer(" New Channel Name "),
+ DefaultCategoryName: model.NewPointer(" New Category "),
}
patchedChannel, appErr := th.App.PatchChannel(th.Context, channel, patch, channel.CreatorId)
@@ -3931,14 +3933,14 @@ func TestPatchChannelWithCategorySorting(t *testing.T) {
// Test that category is not updated when feature is disabled
th.App.UpdateConfig(func(cfg *model.Config) {
- *cfg.ExperimentalSettings.ExperimentalChannelCategorySorting = false
+ *cfg.TeamSettings.EnableChannelCategorySorting = false
})
patch = &model.ChannelPatch{
DisplayName: model.NewPointer("Disabled Category/Channel Name"),
}
- patchedChannel, appErr = th.App.PatchChannel(th.Context, channel, patch, channel.CreatorId)
+ patchedChannel, appErr = th.App.PatchChannel(th.Context, patchedChannel, patch, patchedChannel.CreatorId)
require.Nil(t, appErr)
require.Equal(t, "Disabled Category/Channel Name", patchedChannel.DisplayName)
require.Equal(t, "New Category", patchedChannel.DefaultCategoryName)
diff --git a/server/channels/app/migrations.go b/server/channels/app/migrations.go
index 017bcfc91e1..85ceb7c01bd 100644
--- a/server/channels/app/migrations.go
+++ b/server/channels/app/migrations.go
@@ -32,6 +32,7 @@ const (
contentFlaggingSetupDoneKey = "content_flagging_setup_done"
contentFlaggingMigrationVersion = "v5"
managedCategorySetupDoneKey = "managed_category_setup_done"
+ managedCategoryMigrationVersion = "v2"
cpaDisplayNameBackfillKey = "cpa_display_name_backfill_done"
contentFlaggingPropertyNameFlaggedPostId = "flagged_post_id"
@@ -776,6 +777,18 @@ func (s *Server) doSetupManagedCategoryProperties() error {
}
if data != nil {
+ if data.Value == managedCategoryMigrationVersion {
+ return s.cacheManagedCategoryIDs()
+ }
+
+ if incrementErr := s.Store().PropertyGroup().IncrementVersion(model.ManagedCategoryPropertyGroupName); incrementErr != nil {
+ return fmt.Errorf("failed to increment managed category group version: %w", incrementErr)
+ }
+
+ if saveErr := s.Store().System().SaveOrUpdate(&model.System{Name: managedCategorySetupDoneKey, Value: managedCategoryMigrationVersion}); saveErr != nil {
+ return fmt.Errorf("failed to save managed category setup done flag: %w", saveErr)
+ }
+
return s.cacheManagedCategoryIDs()
}
diff --git a/server/channels/app/migrations_test.go b/server/channels/app/migrations_test.go
index d397425a931..62441b0d2cc 100644
--- a/server/channels/app/migrations_test.go
+++ b/server/channels/app/migrations_test.go
@@ -12,6 +12,71 @@ import (
"github.com/stretchr/testify/require"
)
+func TestDoSetupManagedCategoryProperties(t *testing.T) {
+ t.Run("should register the property group and field on fresh install", func(t *testing.T) {
+ th := Setup(t)
+
+ group, appErr := th.App.GetPropertyGroup(th.Context, model.ManagedCategoryPropertyGroupName)
+ require.Nil(t, appErr)
+ require.NotNil(t, group)
+ require.Equal(t, model.ManagedCategoryPropertyGroupName, group.Name)
+
+ propertyFields, appErr := th.App.SearchPropertyFields(th.Context, group.ID, model.PropertyFieldSearchOpts{PerPage: 100})
+ require.Nil(t, appErr)
+ require.Len(t, propertyFields, 1)
+ require.Equal(t, model.ManagedCategoryPropertyFieldName, propertyFields[0].Name)
+
+ data, sysErr := th.Store.System().GetByName(managedCategorySetupDoneKey)
+ require.NoError(t, sysErr)
+ require.NotEmpty(t, data.Value)
+ })
+
+ t.Run("should upgrade from a pre-v2 setup by incrementing the group version and updating the system key", func(t *testing.T) {
+ th := Setup(t)
+
+ group, appErr := th.App.GetPropertyGroup(th.Context, model.ManagedCategoryPropertyGroupName)
+ require.Nil(t, appErr)
+
+ // Simulate the pre-v2 state where the migration has run with the legacy "true" marker.
+ sysErr := th.Store.System().SaveOrUpdate(&model.System{Name: managedCategorySetupDoneKey, Value: "true"})
+ require.NoError(t, sysErr)
+ initialVersion := group.Version
+
+ err := th.Server.doSetupManagedCategoryProperties()
+ require.NoError(t, err)
+
+ group, appErr = th.App.GetPropertyGroup(th.Context, model.ManagedCategoryPropertyGroupName)
+ require.Nil(t, appErr)
+ require.Equal(t, initialVersion+1, group.Version)
+
+ data, sysErr := th.Store.System().GetByName(managedCategorySetupDoneKey)
+ require.NoError(t, sysErr)
+ require.Equal(t, managedCategoryMigrationVersion, data.Value)
+ })
+
+ t.Run("should be idempotent when the system key is already at v2", func(t *testing.T) {
+ th := Setup(t)
+
+ sysErr := th.Store.System().SaveOrUpdate(&model.System{Name: managedCategorySetupDoneKey, Value: managedCategoryMigrationVersion})
+ require.NoError(t, sysErr)
+
+ group, appErr := th.App.GetPropertyGroup(th.Context, model.ManagedCategoryPropertyGroupName)
+ require.Nil(t, appErr)
+ versionBefore := group.Version
+
+ err := th.Server.doSetupManagedCategoryProperties()
+ require.NoError(t, err)
+
+ group, appErr = th.App.GetPropertyGroup(th.Context, model.ManagedCategoryPropertyGroupName)
+ require.Nil(t, appErr)
+ require.Equal(t, versionBefore, group.Version)
+
+ data, sysErr := th.Store.System().GetByName(managedCategorySetupDoneKey)
+ require.NoError(t, sysErr)
+ require.Equal(t, managedCategoryMigrationVersion, data.Value)
+ })
+}
+
func TestDoSetupContentFlaggingProperties(t *testing.T) {
t.Run("should register property group and fields", func(t *testing.T) {
//we need to call the Setup method and run the full setup instead of
diff --git a/server/channels/app/plugin_api_test.go b/server/channels/app/plugin_api_test.go
index 9863beab20c..352b1ee53a9 100644
--- a/server/channels/app/plugin_api_test.go
+++ b/server/channels/app/plugin_api_test.go
@@ -3717,6 +3717,10 @@ func TestPluginAPICreateChannelManagedCategory(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
+ th.ConfigStore.SetReadOnlyFF(false)
+ t.Cleanup(func() {
+ th.ConfigStore.SetReadOnlyFF(true)
+ })
api := th.SetupPluginAPI()
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
@@ -3724,8 +3728,8 @@ func TestPluginAPICreateChannelManagedCategory(t *testing.T) {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
}()
- th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.EnableManagedChannelCategories = true })
- defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.EnableManagedChannelCategories = false })
+ th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.ManagedChannelCategories = true })
+ defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.ManagedChannelCategories = false })
categoryName := "Operations"
channel := &model.Channel{
diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go
index 755d759792c..859bba8e7d5 100644
--- a/server/channels/store/retrylayer/retrylayer.go
+++ b/server/channels/store/retrylayer/retrylayer.go
@@ -10164,6 +10164,27 @@ func (s *RetryLayerPropertyGroupStore) GetByID(id string) (*model.PropertyGroup,
}
+func (s *RetryLayerPropertyGroupStore) IncrementVersion(name string) error {
+
+ tries := 0
+ for {
+ err := s.PropertyGroupStore.IncrementVersion(name)
+ if err == nil {
+ return nil
+ }
+ if !isRepeatableError(err) {
+ return err
+ }
+ tries++
+ if tries >= 3 {
+ err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
+ return err
+ }
+ timepkg.Sleep(100 * timepkg.Millisecond)
+ }
+
+}
+
func (s *RetryLayerPropertyGroupStore) Register(group *model.PropertyGroup) (*model.PropertyGroup, error) {
tries := 0
diff --git a/server/channels/store/sqlstore/property_group_store.go b/server/channels/store/sqlstore/property_group_store.go
index 3fcaa586061..52ed9ce8fc4 100644
--- a/server/channels/store/sqlstore/property_group_store.go
+++ b/server/channels/store/sqlstore/property_group_store.go
@@ -58,6 +58,20 @@ func (s *SqlPropertyGroupStore) Register(group *model.PropertyGroup) (*model.Pro
return group, nil
}
+func (s *SqlPropertyGroupStore) IncrementVersion(name string) error {
+ builder := s.getQueryBuilder().
+ Update("PropertyGroups").
+ Set("Version", sq.Expr("Version + 1")).
+ Where(sq.Eq{"Name": name})
+
+ _, err := s.GetMaster().ExecBuilder(builder)
+ if err != nil {
+ return errors.Wrap(err, "property_group_increment_version_exec")
+ }
+
+ return nil
+}
+
func (s *SqlPropertyGroupStore) Get(name string) (*model.PropertyGroup, error) {
queryString, args, err := s.getQueryBuilder().
Select(propertyGroupColumns...).
diff --git a/server/channels/store/store.go b/server/channels/store/store.go
index 3d090500a5f..d8cb593298d 100644
--- a/server/channels/store/store.go
+++ b/server/channels/store/store.go
@@ -1133,6 +1133,7 @@ type ScheduledPostStore interface {
type PropertyGroupStore interface {
Register(group *model.PropertyGroup) (*model.PropertyGroup, error)
+ IncrementVersion(name string) error
Get(name string) (*model.PropertyGroup, error)
GetByID(id string) (*model.PropertyGroup, error)
}
diff --git a/server/channels/store/storetest/mocks/PropertyGroupStore.go b/server/channels/store/storetest/mocks/PropertyGroupStore.go
index 4155f4de3eb..bfdaac881b6 100644
--- a/server/channels/store/storetest/mocks/PropertyGroupStore.go
+++ b/server/channels/store/storetest/mocks/PropertyGroupStore.go
@@ -74,6 +74,24 @@ func (_m *PropertyGroupStore) GetByID(id string) (*model.PropertyGroup, error) {
return r0, r1
}
+// IncrementVersion provides a mock function with given fields: name
+func (_m *PropertyGroupStore) IncrementVersion(name string) error {
+ ret := _m.Called(name)
+
+ if len(ret) == 0 {
+ panic("no return value specified for IncrementVersion")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string) error); ok {
+ r0 = rf(name)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
// Register provides a mock function with given fields: group
func (_m *PropertyGroupStore) Register(group *model.PropertyGroup) (*model.PropertyGroup, error) {
ret := _m.Called(group)
diff --git a/server/channels/store/storetest/property_group_store.go b/server/channels/store/storetest/property_group_store.go
index fa8eea1b3b6..0af6df9393c 100644
--- a/server/channels/store/storetest/property_group_store.go
+++ b/server/channels/store/storetest/property_group_store.go
@@ -14,6 +14,7 @@ import (
func TestPropertyGroupStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
t.Run("RegisterAndGetPropertyGroup", func(t *testing.T) { testRegisterAndGetPropertyGroup(t, rctx, ss) })
+ t.Run("IncrementVersion", func(t *testing.T) { testIncrementVersion(t, rctx, ss) })
}
func testRegisterAndGetPropertyGroup(t *testing.T, _ request.CTX, ss store.Store) {
@@ -119,3 +120,67 @@ func testRegisterAndGetPropertyGroup(t *testing.T, _ request.CTX, ss store.Store
require.Error(t, err)
})
}
+
+func testIncrementVersion(t *testing.T, _ request.CTX, ss store.Store) {
+ t.Run("should increment version of an existing group", func(t *testing.T) {
+ registered, err := ss.PropertyGroup().Register(&model.PropertyGroup{
+ Name: "increment_version_test",
+ Version: model.PropertyGroupVersionV1,
+ })
+ require.NoError(t, err)
+ require.Equal(t, model.PropertyGroupVersionV1, registered.Version)
+
+ err = ss.PropertyGroup().IncrementVersion("increment_version_test")
+ require.NoError(t, err)
+
+ fetched, err := ss.PropertyGroup().Get("increment_version_test")
+ require.NoError(t, err)
+ require.Equal(t, model.PropertyGroupVersionV2, fetched.Version)
+ })
+
+ t.Run("should be able to increment multiple times", func(t *testing.T) {
+ _, err := ss.PropertyGroup().Register(&model.PropertyGroup{
+ Name: "increment_version_multi_test",
+ Version: model.PropertyGroupVersionV1,
+ })
+ require.NoError(t, err)
+
+ err = ss.PropertyGroup().IncrementVersion("increment_version_multi_test")
+ require.NoError(t, err)
+ err = ss.PropertyGroup().IncrementVersion("increment_version_multi_test")
+ require.NoError(t, err)
+
+ fetched, err := ss.PropertyGroup().Get("increment_version_multi_test")
+ require.NoError(t, err)
+ require.Equal(t, 3, fetched.Version)
+ })
+
+ t.Run("should not error when incrementing a non-existent group", func(t *testing.T) {
+ err := ss.PropertyGroup().IncrementVersion("non_existent_group_for_increment")
+ require.NoError(t, err)
+ })
+
+ t.Run("should not affect groups with different names", func(t *testing.T) {
+ _, err := ss.PropertyGroup().Register(&model.PropertyGroup{
+ Name: "increment_isolated_a",
+ Version: model.PropertyGroupVersionV1,
+ })
+ require.NoError(t, err)
+ _, err = ss.PropertyGroup().Register(&model.PropertyGroup{
+ Name: "increment_isolated_b",
+ Version: model.PropertyGroupVersionV1,
+ })
+ require.NoError(t, err)
+
+ err = ss.PropertyGroup().IncrementVersion("increment_isolated_a")
+ require.NoError(t, err)
+
+ a, err := ss.PropertyGroup().Get("increment_isolated_a")
+ require.NoError(t, err)
+ require.Equal(t, model.PropertyGroupVersionV2, a.Version)
+
+ b, err := ss.PropertyGroup().Get("increment_isolated_b")
+ require.NoError(t, err)
+ require.Equal(t, model.PropertyGroupVersionV1, b.Version)
+ })
+}
diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go
index 75fba604060..8828701e447 100644
--- a/server/channels/store/timerlayer/timerlayer.go
+++ b/server/channels/store/timerlayer/timerlayer.go
@@ -8108,6 +8108,22 @@ func (s *TimerLayerPropertyGroupStore) GetByID(id string) (*model.PropertyGroup,
return result, err
}
+func (s *TimerLayerPropertyGroupStore) IncrementVersion(name string) error {
+ start := time.Now()
+
+ err := s.PropertyGroupStore.IncrementVersion(name)
+
+ elapsed := float64(time.Since(start)) / float64(time.Second)
+ if s.Root.Metrics != nil {
+ success := "false"
+ if err == nil {
+ success = "true"
+ }
+ s.Root.Metrics.ObserveStoreMethodDuration("PropertyGroupStore.IncrementVersion", success, elapsed)
+ }
+ return err
+}
+
func (s *TimerLayerPropertyGroupStore) Register(group *model.PropertyGroup) (*model.PropertyGroup, error) {
start := time.Now()
diff --git a/server/channels/testlib/store.go b/server/channels/testlib/store.go
index 014dc92ab76..7e2e9f98fd3 100644
--- a/server/channels/testlib/store.go
+++ b/server/channels/testlib/store.go
@@ -40,6 +40,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store {
systemStore.On("GetByName", "PlaybookRolesCreationMigrationComplete").Return(&model.System{Name: "PlaybookRolesCreationMigrationComplete", Value: "true"}, nil)
systemStore.On("GetByName", "PostPriorityConfigDefaultTrueMigrationComplete").Return(&model.System{Name: "PostPriorityConfigDefaultTrueMigrationComplete", Value: "true"}, nil)
systemStore.On("GetByName", "content_flagging_setup_done").Return(&model.System{Name: "content_flagging_setup_done", Value: "true"}, nil)
+ systemStore.On("GetByName", "managed_category_setup_done").Return(&model.System{Name: "managed_category_setup_done", Value: "v2"}, nil)
systemStore.On("GetByName", "managed_category_setup_done").Return(&model.System{Name: "managed_category_setup_done", Value: "true"}, nil)
systemStore.On("GetByName", "cpa_display_name_backfill_done").Return(&model.System{Name: "cpa_display_name_backfill_done", Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyEmojiPermissionsSplit).Return(&model.System{Name: model.MigrationKeyEmojiPermissionsSplit, Value: "true"}, nil)
diff --git a/server/config/client.go b/server/config/client.go
index dff8580e68e..d56c9d7e70e 100644
--- a/server/config/client.go
+++ b/server/config/client.go
@@ -24,6 +24,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
props["LockTeammateNameDisplay"] = strconv.FormatBool(*c.TeamSettings.LockTeammateNameDisplay)
props["ExperimentalPrimaryTeam"] = *c.TeamSettings.ExperimentalPrimaryTeam
props["EnableJoinLeaveMessageByDefault"] = strconv.FormatBool(*c.TeamSettings.EnableJoinLeaveMessageByDefault)
+ props["EnableChannelCategorySorting"] = strconv.FormatBool(*c.TeamSettings.EnableChannelCategorySorting)
props["EnableBotAccountCreation"] = strconv.FormatBool(*c.ServiceSettings.EnableBotAccountCreation)
props["EnableDesktopLandingPage"] = strconv.FormatBool(*c.ServiceSettings.EnableDesktopLandingPage)
@@ -103,8 +104,6 @@ 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)
props["FeatureFlagExperimentalCrossTeamSearch"] = strconv.FormatBool(*c.ServiceSettings.EnableCrossTeamSearch)
@@ -242,7 +241,6 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
props["MobilePreventScreenCapture"] = strconv.FormatBool(*c.NativeAppSettings.MobilePreventScreenCapture)
props["MobileJailbreakProtection"] = strconv.FormatBool(*c.NativeAppSettings.MobileJailbreakProtection)
props["ExperimentalEnableWatermark"] = strconv.FormatBool(*c.ExperimentalSettings.EnableWatermark)
- props["EnableManagedChannelCategories"] = strconv.FormatBool(*c.TeamSettings.EnableManagedChannelCategories)
}
if model.MinimumEnterpriseAdvancedLicense(license) {
diff --git a/server/i18n/en.json b/server/i18n/en.json
index f9307ba6f63..93cd2dfd278 100644
--- a/server/i18n/en.json
+++ b/server/i18n/en.json
@@ -2628,10 +2628,6 @@
"id": "api.license_error",
"translation": "api endpoint requires a license"
},
- {
- "id": "api.managed_category.feature_not_available.app_error",
- "translation": "Managed channel categories are not available."
- },
{
"id": "api.marshal_error",
"translation": "Failed to marshal."
diff --git a/server/public/model/channel.go b/server/public/model/channel.go
index 61cd5690641..6c9c7056db2 100644
--- a/server/public/model/channel.go
+++ b/server/public/model/channel.go
@@ -155,13 +155,16 @@ type ChannelPatch struct {
BannerInfo *ChannelBannerInfo `json:"banner_info"`
AutoTranslation *bool `json:"autotranslation"`
ManagedCategoryName *string `json:"managed_category_name"`
+ DefaultCategoryName *string `json:"default_category_name"`
}
func (c *ChannelPatch) Auditable() map[string]any {
return map[string]any{
- "header": c.Header,
- "group_constrained": c.GroupConstrained,
- "purpose": c.Purpose,
+ "header": c.Header,
+ "group_constrained": c.GroupConstrained,
+ "purpose": c.Purpose,
+ "default_category_name": c.DefaultCategoryName,
+ "managed_category_name": c.ManagedCategoryName,
}
}
@@ -406,6 +409,10 @@ func (o *Channel) Patch(patch *ChannelPatch) {
if patch.AutoTranslation != nil {
o.AutoTranslation = *patch.AutoTranslation
}
+
+ if patch.DefaultCategoryName != nil {
+ o.DefaultCategoryName = strings.TrimSpace(*patch.DefaultCategoryName)
+ }
}
func (o *Channel) MakeNonNil() {
diff --git a/server/public/model/config.go b/server/public/model/config.go
index e7bd85b0606..184d71f329f 100644
--- a/server/public/model/config.go
+++ b/server/public/model/config.go
@@ -1217,7 +1217,6 @@ type ExperimentalSettings struct {
DisableWakeUpReconnectHandler *bool `access:"experimental_features"`
UsersStatusAndProfileFetchingPollIntervalMilliseconds *int64 `access:"experimental_features"`
YoutubeReferrerPolicy *bool `access:"experimental_features"`
- ExperimentalChannelCategorySorting *bool `access:"experimental_features"`
EnableWatermark *bool `access:"experimental_features"`
}
@@ -1266,10 +1265,6 @@ func (s *ExperimentalSettings) SetDefaults() {
s.YoutubeReferrerPolicy = NewPointer(false)
}
- if s.ExperimentalChannelCategorySorting == nil {
- s.ExperimentalChannelCategorySorting = NewPointer(false)
- }
-
if s.EnableWatermark == nil {
s.EnableWatermark = NewPointer(false)
}
@@ -2424,7 +2419,7 @@ type TeamSettings struct {
// In seconds.
UserStatusAwayTimeout *int64 `access:"experimental_features"`
MaxChannelsPerTeam *int64 `access:"site_users_and_teams"`
- EnableManagedChannelCategories *bool `access:"site_users_and_teams"`
+ EnableChannelCategorySorting *bool `access:"site_users_and_teams"`
MaxNotificationsPerChannel *int64 `access:"environment_push_notification_server"`
EnableConfirmNotificationsToChannel *bool `access:"site_notifications"`
TeammateNameDisplay *string `access:"site_users_and_teams"`
@@ -2497,8 +2492,8 @@ func (s *TeamSettings) SetDefaults() {
s.MaxChannelsPerTeam = NewPointer(int64(2000))
}
- if s.EnableManagedChannelCategories == nil {
- s.EnableManagedChannelCategories = NewPointer(false)
+ if s.EnableChannelCategorySorting == nil {
+ s.EnableChannelCategorySorting = NewPointer(true)
}
if s.MaxNotificationsPerChannel == nil {
diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go
index cf88086d39c..44a1601d7f5 100644
--- a/server/public/model/feature_flags.go
+++ b/server/public/model/feature_flags.go
@@ -114,6 +114,9 @@ type FeatureFlags struct {
// Collect plugin metrics and serve them on the /metrics endpoint
AggregatePluginMetrics bool
+
+ // ManagedChannelCategories enables server-side managed sidebar category enforcement (Enterprise).
+ ManagedChannelCategories bool
}
func (f *FeatureFlags) SetDefaults() {
@@ -167,6 +170,8 @@ func (f *FeatureFlags) SetDefaults() {
f.CJKSearch = false
f.AggregatePluginMetrics = false
+
+ f.ManagedChannelCategories = false
}
// ToMap returns the feature flags as a map[string]string
diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx
index 6f67f51636c..29b930deec1 100644
--- a/webapp/channels/src/components/admin_console/admin_definition.tsx
+++ b/webapp/channels/src/components/admin_console/admin_definition.tsx
@@ -2708,11 +2708,10 @@ const AdminDefinition: AdminDefinitionType = {
},
{
type: 'bool',
- key: 'TeamSettings.EnableManagedChannelCategories',
- label: defineMessage({id: 'admin.team.managedChannelCategoriesTitle', defaultMessage: 'Managed channel categories:'}),
- help_text: defineMessage({id: 'admin.team.managedChannelCategoriesDescription', defaultMessage: 'Enables teams to have fixed sidebar categories to organize channels for all members of the team. Channel admins can create categories and organize their channels.'}),
+ key: 'TeamSettings.EnableChannelCategorySorting',
+ label: defineMessage({id: 'admin.team.enableChannelCategorySortingTitle', defaultMessage: 'Channel category sorting:'}),
+ help_text: defineMessage({id: 'admin.team.enableChannelCategorySortingDescription', defaultMessage: 'When true, channel admins can choose a default sidebar category when creating or editing a channel. New channel members will automatically have a category created that contains the channel for them when joining the channel.'}),
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.USERS_AND_TEAMS)),
- isHidden: it.not(it.minLicenseTier(LicenseSkus.Enterprise)),
},
{
type: 'bool',
@@ -6446,13 +6445,6 @@ const AdminDefinition: AdminDefinitionType = {
help_text: defineMessage({id: 'admin.experimental.youtubeReferrerPolicy.desc', defaultMessage: 'When true, the referrer policy for embedded YouTube videos will be set to "strict-origin-when-cross-origin" which resolves issues where YouTube video previews display as unavailable, while balancing the need to protect user privacy with some degree of referral data to support web functionalities, like analytics, logging, and third-party integrations. When false, the referrer policy will be set to "no-referrer" which enhances user privacy by not disclosing the source URL, but limits the ability to track user engagement and traffic sources in analytics tools.'}),
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
- {
- type: 'bool',
- key: 'ExperimentalSettings.ExperimentalChannelCategorySorting',
- label: defineMessage({id: 'admin.experimental.channelCategorySorting.title', defaultMessage: 'Channel Category Sorting:'}),
- help_text: defineMessage({id: 'admin.experimental.channelCategorySorting.desc', defaultMessage: 'When true, channels will be automatically sorted into categories based on their names using a "/" delimiter.'}),
- isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
- },
{
type: 'bool',
key: 'ExperimentalSettings.EnableWatermark',
diff --git a/webapp/channels/src/components/channel_settings_modal/managed_category_selector.scss b/webapp/channels/src/components/category_selector/category_selector.scss
similarity index 64%
rename from webapp/channels/src/components/channel_settings_modal/managed_category_selector.scss
rename to webapp/channels/src/components/category_selector/category_selector.scss
index a8f5cd1c736..e2066851da4 100644
--- a/webapp/channels/src/components/channel_settings_modal/managed_category_selector.scss
+++ b/webapp/channels/src/components/category_selector/category_selector.scss
@@ -1,6 +1,6 @@
-$managed_category_index: 999999;
+$category_selector_index: 999999;
-.ManagedCategorySelector {
+.CategorySelector {
&.Input_container {
margin-top: 24px;
}
@@ -19,13 +19,13 @@ $managed_category_index: 999999;
}
}
-.ManagedCategorySelector__controlContainer {
- .ManagedCategory__control {
+.CategorySelector__controlContainer {
+ .CategorySelector__control {
border: none;
background-color: var(--center-channel-bg);
box-shadow: none;
- .ManagedCategory__input {
+ .CategorySelector__input {
box-shadow: none;
color: var(--center-channel-color);
}
@@ -36,19 +36,19 @@ $managed_category_index: 999999;
}
}
-.ManagedCategory__menu {
+.CategorySelector__menu {
background-color: var(--center-channel-bg) !important;
}
-.ManagedCategorySelector__createLabelPrefix {
+.CategorySelector__createLabelPrefix {
opacity: 0.75;
}
-.ManagedCategory__indicator-separator {
+.CategorySelector__indicator-separator {
display: none;
}
-.ManagedCategorySelector__valueContainerInner {
+.CategorySelector__valueContainerInner {
display: flex;
overflow: hidden;
flex: 1;
@@ -56,21 +56,21 @@ $managed_category_index: 999999;
gap: 6px;
}
-.ManagedCategorySelector__folderIcon {
+.CategorySelector__folderIcon {
flex-shrink: 0;
color: rgba(var(--center-channel-color-rgb), 0.64);
}
-.ManagedCategory__placeholder, .ManagedCategory__single-value {
+.CategorySelector__placeholder, .CategorySelector__single-value {
position: absolute;
left: 22px;
}
-.ManagedCategory__single-value {
+.CategorySelector__single-value {
color: var(--center-channel-color) !important;
}
-.ManagedCategorySelector__indicatorsContainer {
+.CategorySelector__indicatorsContainer {
margin-right: 8px;
i {
@@ -83,28 +83,28 @@ $managed_category_index: 999999;
}
}
-.ManagedCategorySelector__option > div {
- z-index: $managed_category_index;
+.CategorySelector__option > div {
+ z-index: $category_selector_index;
padding: 8px 16px;
cursor: pointer;
line-height: 20px;
}
-.ManagedCategorySelector__option.selected > div {
+.CategorySelector__option.selected > div {
background-color: rgba(var(--button-bg-rgb), 0.08);
color: var(--center-channel-color);
}
-.ManagedCategorySelector__option.focused > div {
+.CategorySelector__option.focused > div {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
-.ManagedCategorySelector__option .ManagedCategory__option {
+.CategorySelector__option .CategorySelector__option {
display: flex;
align-items: center;
gap: 12px;
}
-.ManagedCategorySelector__option svg {
+.CategorySelector__option svg {
color: rgba(var(--center-channel-color-rgb), 0.64);
}
diff --git a/webapp/channels/src/components/category_selector/category_selector.test.tsx b/webapp/channels/src/components/category_selector/category_selector.test.tsx
new file mode 100644
index 00000000000..afd7f3f0ff4
--- /dev/null
+++ b/webapp/channels/src/components/category_selector/category_selector.test.tsx
@@ -0,0 +1,156 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+import {makeGetSidebarCategoryNamesForTeam} from 'mattermost-redux/selectors/entities/channel_categories';
+
+import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
+
+import CategorySelector from './category_selector';
+
+const baseState = {
+ entities: {
+ general: {
+ config: {FeatureFlagManagedChannelCategories: 'true'},
+ },
+ teams: {
+ currentTeamId: 'team1',
+ },
+ channelCategories: {
+ byId: {},
+ orderByTeam: {},
+ managedCategoryMappings: {
+ team1: {
+ channel1: 'Operations',
+ channel2: 'Operations',
+ channel3: 'Intel',
+ },
+ },
+ },
+ },
+};
+
+describe('CategorySelector', () => {
+ const baseProps = {
+ value: '',
+ onChange: jest.fn(),
+ getOptions: makeGetSidebarCategoryNamesForTeam(),
+ };
+
+ beforeEach(() => {
+ baseProps.onChange.mockClear();
+ });
+
+ it('should render with the selected value', () => {
+ renderWithContext(
+