[MM-68608] Feature flag Managed Categories (#36345)

* [MM-68608] Feature flag Managed Categories

* Update server/channels/api4/channel_test.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix i18n

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Devin Binnie
2026-04-30 12:45:59 -04:00
committed by GitHub
parent a0947f9201
commit d0c2842145
17 changed files with 89 additions and 704 deletions
+18 -7
View File
@@ -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
@@ -1148,8 +1157,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
@@ -1175,8 +1184,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:
@@ -1,631 +0,0 @@
// 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 enableManagedCategories(adminClient: any) {
await adminClient.patchConfig({
TeamSettings: {
EnableManagedChannelCategories: true,
},
});
}
async function disableManagedCategories(adminClient: any) {
await adminClient.patchConfig({
TeamSettings: {
EnableManagedChannelCategories: false,
},
});
}
async function createChannelWithManagedCategory(
adminClient: any,
teamId: string,
categoryName: string,
channelSuffix: string,
) {
const channel = await adminClient.createChannel({
team_id: teamId,
name: `managed-cat-${channelSuffix}-${Date.now()}`,
display_name: `Managed ${channelSuffix} ${Date.now()}`,
type: 'O',
});
await adminClient.patchChannel(channel.id, {managed_category_name: categoryName});
return channel;
}
test.describe('Managed Channel Categories', () => {
/**
* @objective Verify that a Channel Admin can assign a managed category to a channel via the channel settings modal,
* and the category appears in the sidebar with the channel under it.
*/
test(
'Channel Admin can assign a managed category via channel settings',
{tag: '@managed_categories'},
async ({pw}) => {
// # Initialize setup with admin user and enterprise license
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(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 = `managed-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 managed category selector is visible
const managedSelector = channelSettingsModal.container.locator('.ManagedCategory__control');
await expect(managedSelector).toBeVisible();
// # Click the selector, type a new category name, and select "Create new category"
await managedSelector.click();
const input = channelSettingsModal.container.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 managed 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 managed category from a channel via the channel settings modal,
* and the channel returns to the default CHANNELS section.
*/
test(
'Channel Admin can remove a managed category via channel settings',
{tag: '@managed_categories'},
async ({pw}) => {
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'Removable', 'remove');
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 managed category is visible in the sidebar
const sidebar = channelsPage.sidebarLeft.container;
await expect(sidebar.getByText('Removable')).toBeVisible();
// # Open channel settings and click the clear button to remove the category
const channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.openInfoTab();
const clearButton = channelSettingsModal.container.locator('.ManagedCategory__clear-indicator');
await expect(clearButton).toBeVisible();
await clearButton.click();
await pw.wait(pw.duration.half_sec);
// * Verify the clear button is gone
await expect(clearButton).not.toBeVisible();
// # Save and close
await channelSettingsModal.save();
await pw.wait(pw.duration.two_sec);
await channelSettingsModal.close();
// * Verify the managed category is removed and the channel is back under CHANNELS
await expect(sidebar.getByText('Removable')).not.toBeVisible();
const channelsSection = sidebar.locator('.SidebarChannelGroup').filter({hasText: 'CHANNELS'});
await expect(channelsSection.locator(`#sidebarItem_${channel.name}`)).toBeVisible();
},
);
/**
* @objective Verify that the managed category selector is not visible in channel settings when the feature is disabled.
*/
test(
'managed category selector is not visible when feature is disabled',
{tag: '@managed_categories'},
async ({pw}) => {
// # Initialize setup and disable managed categories
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await disableManagedCategories(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 managed category selector is not visible
const managedSelector = channelSettingsModal.container.locator('.ManagedCategory__control');
await expect(managedSelector).not.toBeVisible();
await channelSettingsModal.close();
},
);
/**
* @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}) => {
// # Initialize setup and enable managed categories
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(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 Managed ${Date.now()}`;
await newChannelModal.fillDisplayName(displayName);
// * Verify managed category selector is visible
const managedSelector = newChannelModal.container.locator('.ManagedCategory__control');
await expect(managedSelector).toBeVisible();
// # Select a new managed category and create the channel
await managedSelector.click();
const input = newChannelModal.container.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 managed category appears in the sidebar
const sidebar = channelsPage.sidebarLeft.container;
await expect(sidebar.getByText('Flight Ops')).toBeVisible();
});
/**
* @objective Verify that managed categories appear at the top of the sidebar above personal categories like CHANNELS.
*/
test(
'managed categories appear at the top of the sidebar above personal categories',
{tag: '@managed_categories'},
async ({pw}) => {
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'Alpha Priority', 'alpha');
await adminClient.addToChannel(user.id, channel.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// * Verify the managed category is visible and positioned above CHANNELS
const sidebar = channelsPage.sidebarLeft.container;
const managedCategory = sidebar.getByText('Alpha Priority');
await expect(managedCategory).toBeVisible();
const channelsHeader = sidebar.getByText('CHANNELS', {exact: true});
await expect(channelsHeader).toBeVisible();
const managedBox = await managedCategory.boundingBox();
const channelsBox = await channelsHeader.boundingBox();
expect(managedBox).toBeTruthy();
expect(channelsBox).toBeTruthy();
expect(managedBox!.y).toBeLessThan(channelsBox!.y);
},
);
/**
* @objective Verify that a managed category is only visible to users who are members of at least one channel in it.
*/
test(
'managed category is only visible when user is a member of a channel in it',
{tag: '@managed_categories'},
async ({pw}) => {
// # 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);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
await createChannelWithManagedCategory(adminClient, team.id, 'Secret Ops', 'secret');
// # Log in as regular user who is not a member of the channel
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// * Verify the managed category is not visible
const sidebar = channelsPage.sidebarLeft.container;
await expect(sidebar.getByText('Secret Ops')).not.toBeVisible();
},
);
/**
* @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}) => {
// # Initialize setup and create two channels with the same managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const suffix = Date.now();
const channelB = await adminClient.createChannel({
team_id: team.id,
name: `bravo-${suffix}`,
display_name: `Bravo Channel`,
type: 'O',
});
const channelA = await adminClient.createChannel({
team_id: team.id,
name: `alpha-${suffix}`,
display_name: `Alpha Channel`,
type: 'O',
});
// # Assign both to the same managed category and add user
await adminClient.patchChannel(channelB.id, {managed_category_name: 'Sorted Category'});
await adminClient.patchChannel(channelA.id, {managed_category_name: 'Sorted Category'});
await adminClient.addToChannel(user.id, channelA.id);
await adminClient.addToChannel(user.id, channelB.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// * Verify both channels are visible and Alpha appears before Bravo
const sidebar = channelsPage.sidebarLeft.container;
await expect(sidebar.getByText('Sorted Category')).toBeVisible();
const alphaItem = sidebar.locator(`#sidebarItem_${channelA.name}`);
const bravoItem = sidebar.locator(`#sidebarItem_${channelB.name}`);
await expect(alphaItem).toBeVisible();
await expect(bravoItem).toBeVisible();
const alphaBox = await alphaItem.boundingBox();
const bravoBox = await bravoItem.boundingBox();
expect(alphaBox).toBeTruthy();
expect(bravoBox).toBeTruthy();
expect(alphaBox!.y).toBeLessThan(bravoBox!.y);
});
/**
* @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}) => {
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'No Favorites', 'nofav');
await adminClient.addToChannel(adminUser.id, channel.id);
// # Log in and navigate to the managed channel
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * Verify the favorite button is visible but disabled
const favoriteButton = channelsPage.page.locator('#toggleFavorite');
await expect(favoriteButton).toBeVisible();
await expect(favoriteButton).toBeDisabled();
});
/**
* @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}) => {
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'No Menu', 'nomenu');
await adminClient.addToChannel(user.id, channel.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Right-click on the managed category header
const sidebar = channelsPage.sidebarLeft.container;
const categoryHeader = sidebar.getByText('No Menu');
await expect(categoryHeader).toBeVisible();
await categoryHeader.click({button: 'right'});
await pw.wait(pw.duration.one_sec);
// * Verify no context menu appears
const categoryMenu = channelsPage.page.locator('.SidebarCategoryMenu');
await expect(categoryMenu).not.toBeVisible();
});
/**
* @objective Verify that the Favorite menu item is disabled in the channel options menu for channels in managed categories.
*/
test(
'channel context menu shows favorite as disabled in managed category',
{tag: '@managed_categories'},
async ({pw}) => {
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'Context Menu', 'ctx');
await adminClient.addToChannel(user.id, channel.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Open the channel options menu via the three-dot button
const sidebar = channelsPage.sidebarLeft.container;
const channelItem = sidebar.locator(`#sidebarItem_${channel.name}`);
await expect(channelItem).toBeVisible();
await channelItem.hover();
const menuButton = channelItem.getByRole('button', {name: /Channel options/});
await menuButton.click();
// * Verify the Favorite menu item is visible but disabled
const favoriteMenuItem = channelsPage.page.getByRole('menuitem', {name: /Favorite/i});
await expect(favoriteMenuItem).toBeVisible();
const isDisabled = await favoriteMenuItem.evaluate((el) => {
return el.classList.contains('Mui-disabled') || el.getAttribute('aria-disabled') === 'true';
});
expect(isDisabled).toBe(true);
},
);
/**
* @objective Verify that the Move To menu item is disabled for non-admin users on channels in managed categories.
*/
test(
'Move To is disabled for non-admin users on channels in managed categories',
{tag: '@managed_categories'},
async ({pw}) => {
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'No Move', 'nomove');
await adminClient.addToChannel(user.id, channel.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Open the channel options menu via the three-dot button
const sidebar = channelsPage.sidebarLeft.container;
const channelItem = sidebar.locator(`#sidebarItem_${channel.name}`);
await expect(channelItem).toBeVisible();
await channelItem.hover();
const menuButton = channelItem.getByRole('button', {name: /Channel options/});
await menuButton.click();
// * Verify the Move To menu item is visible but disabled
const moveToMenuItem = channelsPage.page.getByRole('menuitem', {name: /Move to/i});
await expect(moveToMenuItem).toBeVisible();
const isDisabled = await moveToMenuItem.evaluate((el) => {
return el.classList.contains('Mui-disabled') || el.getAttribute('aria-disabled') === 'true';
});
expect(isDisabled).toBe(true);
},
);
/**
* @objective Verify that assigning the same managed category name to multiple channels groups them under a single
* category header in the sidebar.
*/
test(
'assigning the same category name to multiple channels groups them together',
{tag: '@managed_categories'},
async ({pw}) => {
// # Initialize setup and create two channels with the same managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const suffix = Date.now();
const channel1 = await createChannelWithManagedCategory(
adminClient,
team.id,
'Shared Category',
`shared1-${suffix}`,
);
const channel2 = await createChannelWithManagedCategory(
adminClient,
team.id,
'Shared Category',
`shared2-${suffix}`,
);
// # Add the user to both channels
await adminClient.addToChannel(user.id, channel1.id);
await adminClient.addToChannel(user.id, channel2.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// * Verify only one category header exists and both channels are under it
const sidebar = channelsPage.sidebarLeft.container;
const categories = sidebar.getByText('Shared Category');
await expect(categories).toHaveCount(1);
await expect(sidebar.locator(`#sidebarItem_${channel1.name}`)).toBeVisible();
await expect(sidebar.locator(`#sidebarItem_${channel2.name}`)).toBeVisible();
},
);
/**
* @objective Verify that when an admin assigns a managed category to a channel, the category appears in the
* sidebar of other users in real-time via websocket.
*/
test(
'managed category appears in real-time when admin assigns a channel to it',
{tag: '@managed_categories'},
async ({pw}) => {
// # Initialize setup and create a channel without a managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `realtime-${Date.now()}`,
display_name: `Realtime Channel ${Date.now()}`,
type: 'O',
});
await adminClient.addToChannel(user.id, channel.id);
// # Log in as regular user
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// * Verify the managed category does not exist yet
const sidebar = channelsPage.sidebarLeft.container;
await expect(sidebar.getByText('Realtime Ops')).not.toBeVisible();
// # Admin assigns a managed category to the channel via API
await adminClient.patchChannel(channel.id, {managed_category_name: 'Realtime Ops'});
// * Verify the managed category appears in real-time
await pw.waitUntil(
async () => {
return await sidebar
.getByText('Realtime Ops')
.isVisible()
.catch(() => false);
},
{timeout: 10000},
);
await expect(sidebar.getByText('Realtime Ops')).toBeVisible();
await expect(sidebar.locator(`#sidebarItem_${channel.name}`)).toBeVisible();
},
);
/**
* @objective Verify that the Enable Managed Channel Categories setting is available in the System Console
* under Site Configuration > Users and Teams.
*/
test(
'Enable Managed Channel Categories setting is available in System Console',
{tag: '@managed_categories'},
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.EnableManagedChannelCategoriestrue',
);
await expect(setting).toBeVisible();
},
);
/**
* @objective Verify that a non-channel-admin user sees the managed category selector as disabled in channel settings.
*/
test(
'non-channel-admin sees the managed category selector as disabled',
{tag: '@managed_categories'},
async ({pw}) => {
// # Initialize setup and create a channel with a managed category
const {adminUser, adminClient, team, user} = await pw.initSetup({withDefaultProfileImage: false});
await skipIfNoEnterpriseLicense(adminClient);
await enableManagedCategories(adminClient);
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createChannelWithManagedCategory(adminClient, team.id, 'Locked', 'locked');
await adminClient.addToChannel(user.id, channel.id);
// # Log in as regular user and open channel settings
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const channelSettingsModal = await channelsPage.openChannelSettings();
await channelSettingsModal.openInfoTab();
// * Verify the managed category selector is disabled
const disabledControl = channelSettingsModal.container.locator('.ManagedCategory__control--is-disabled');
await expect(disabledControl).toBeVisible();
await channelSettingsModal.close();
},
);
});
+5 -8
View File
@@ -36,7 +36,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)
@@ -430,7 +432,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
@@ -454,7 +456,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
@@ -2934,11 +2936,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
+40 -22
View File
@@ -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)
@@ -7463,7 +7469,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) {
@@ -7472,26 +7480,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)
@@ -7508,7 +7502,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)
@@ -7526,17 +7519,42 @@ func TestGetManagedCategories(t *testing.T) {
})
}
func TestGetManagedCategoriesFeatureFlagDisabled(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.ManagedChannelCategories = false
}).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 := 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
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()
+4 -3
View File
@@ -17,9 +17,10 @@ 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)
if api.srv.Config().FeatureFlags.IntegratedBoards {
if api.srv.Config().FeatureFlags.IntegratedBoards || api.srv.Config().FeatureFlags.ManagedChannelCategories {
api.BaseRoutes.PropertyFields.Handle("", api.APISessionRequired(getPropertyFields)).Methods(http.MethodGet)
api.BaseRoutes.PropertyValues.Handle("", api.APISessionRequired(getPropertyValues)).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)
+1 -1
View File
@@ -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 {
+6 -2
View File
@@ -3604,6 +3604,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))
@@ -3611,8 +3615,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{
-1
View File
@@ -242,7 +242,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) {
-4
View File
@@ -2576,10 +2576,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."
-5
View File
@@ -2424,7 +2424,6 @@ type TeamSettings struct {
// In seconds.
UserStatusAwayTimeout *int64 `access:"experimental_features"`
MaxChannelsPerTeam *int64 `access:"site_users_and_teams"`
EnableManagedChannelCategories *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,10 +2496,6 @@ func (s *TeamSettings) SetDefaults() {
s.MaxChannelsPerTeam = NewPointer(int64(2000))
}
if s.EnableManagedChannelCategories == nil {
s.EnableManagedChannelCategories = NewPointer(false)
}
if s.MaxNotificationsPerChannel == nil {
s.MaxNotificationsPerChannel = NewPointer(int64(1000))
}
+5
View File
@@ -104,6 +104,9 @@ type FeatureFlags struct {
// Enable LIKE-based CJK (Chinese, Japanese, Korean) search for PostgreSQL
CJKSearch bool
// ManagedChannelCategories enables server-side managed sidebar category enforcement (Enterprise).
ManagedChannelCategories bool
}
func (f *FeatureFlags) SetDefaults() {
@@ -152,6 +155,8 @@ func (f *FeatureFlags) SetDefaults() {
f.IntegratedBoards = false
f.CJKSearch = false
f.ManagedChannelCategories = false
}
// ToMap returns the feature flags as a map[string]string
@@ -2700,14 +2700,6 @@ const AdminDefinition: AdminDefinitionType = {
placeholder: defineMessage({id: 'admin.team.maxChannelsExample', defaultMessage: 'E.g.: "100"'}),
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.USERS_AND_TEAMS)),
},
{
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.'}),
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.USERS_AND_TEAMS)),
isHidden: it.not(it.minLicenseTier(LicenseSkus.Enterprise)),
},
{
type: 'bool',
key: 'TeamSettings.EnableJoinLeaveMessageByDefault',
@@ -10,7 +10,7 @@ import ManagedCategorySelector from './managed_category_selector';
const baseState = {
entities: {
general: {
config: {EnableManagedChannelCategories: 'true'},
config: {FeatureFlagManagedChannelCategories: 'true'},
},
teams: {
currentTeamId: 'team1',
-2
View File
@@ -3309,8 +3309,6 @@
"admin.team.invalidateEmailInvitesTitle": "Invalidate pending email invites",
"admin.team.lastActiveTimeDescription": "When enabled, last active time allows users to see when someone was last online.",
"admin.team.lastActiveTimeTitle": "Enable last active time: ",
"admin.team.managedChannelCategoriesDescription": "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.",
"admin.team.managedChannelCategoriesTitle": "Managed channel categories:",
"admin.team.maxChannelsDescription": "Maximum total number of channels per team, including both active and archived channels.",
"admin.team.maxChannelsExample": "E.g.: \"100\"",
"admin.team.maxChannelsTitle": "Max Channels Per Team:",
@@ -1453,7 +1453,7 @@ describe('isChannelInManagedCategory', () => {
const state = {
entities: {
general: {
config: {EnableManagedChannelCategories: 'true'},
config: {FeatureFlagManagedChannelCategories: 'true'},
},
channels: {
channels: {
@@ -1491,7 +1491,7 @@ describe('getChannelManagedCategoryName', () => {
const state = {
entities: {
general: {
config: {EnableManagedChannelCategories: 'true'},
config: {FeatureFlagManagedChannelCategories: 'true'},
},
channels: {
channels: {
@@ -1527,7 +1527,7 @@ describe('makeGetManagedCategoriesForTeam', () => {
const state = {
entities: {
general: {
config: {EnableManagedChannelCategories: 'true'},
config: {FeatureFlagManagedChannelCategories: 'true'},
},
channelCategories: {
managedCategoryMappings: {},
@@ -1547,7 +1547,7 @@ describe('makeGetManagedCategoriesForTeam', () => {
const state = {
entities: {
general: {
config: {EnableManagedChannelCategories: 'true'},
config: {FeatureFlagManagedChannelCategories: 'true'},
},
channelCategories: {
managedCategoryMappings: {
@@ -1610,7 +1610,7 @@ describe('makeGetCategoriesForTeam (merged)', () => {
const state = {
entities: {
general: {
config: {EnableManagedChannelCategories: 'true'},
config: {FeatureFlagManagedChannelCategories: 'true'},
},
channelCategories: {
byId: {favorites1: nonManagedCategory1, channels1: nonManagedCategory2},
@@ -1633,7 +1633,7 @@ describe('makeGetCategoriesForTeam (merged)', () => {
const state = {
entities: {
general: {
config: {EnableManagedChannelCategories: 'true'},
config: {FeatureFlagManagedChannelCategories: 'true'},
},
channelCategories: {
byId: {favorites1: nonManagedCategory1, channels1: nonManagedCategory2},
@@ -1672,7 +1672,7 @@ describe('makeGetCategoriesForTeam (merged)', () => {
const state = {
entities: {
general: {
config: {EnableManagedChannelCategories: 'true'},
config: {FeatureFlagManagedChannelCategories: 'true'},
},
channelCategories: {
byId: {favorites1: nonManagedCategory1, channels1: nonManagedCategory2},
@@ -475,7 +475,7 @@ function isUnreadChannel(
}
export function areManagedCategoriesEnabled(state: GlobalState): boolean {
return getConfig(state).EnableManagedChannelCategories === 'true';
return getConfig(state).FeatureFlagManagedChannelCategories === 'true';
}
export function getManagedCategoryMappings(state: GlobalState, teamId: string): Record<string, string> | undefined {
+1 -1
View File
@@ -64,7 +64,6 @@ export type ClientConfig = {
EnableExperimentalLocales: string;
EnableUserStatuses: string;
EnableLastActiveTime: string;
EnableManagedChannelCategories: string;
EnableTimedDND: string;
EnableCrossTeamSearch: 'true' | 'false';
EnableCustomTermsOfService: string;
@@ -133,6 +132,7 @@ export type ClientConfig = {
FeatureFlagWebSocketEventScope: string;
FeatureFlagInteractiveDialogAppsForm: string;
FeatureFlagContentFlagging: string;
FeatureFlagManagedChannelCategories: string;
ForgotPasswordLink: string;
GiphySdkKey: string;