mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
[MM-68496] Feature flag Managed Categories, expose Default Category Name to UI for channel creation and settings (#36289)
* [MM-68496] Feature flag Managed Categories, expose Default Category Name to UI for Channels * PR feedback * PR feedback * Fix i18n * Fix test * Fix E2E * Merge'd * Add tests * Re-add old tests (skipped) * Add IncrementVersion to PropertyGroup store, increment version on managed category group * Fix lint * Fix mock * Fix prettier * Add tests * Fixed issue when moving from existing category to existing category * Fix e2e --------- Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
parent
d9b0e27fe8
commit
69fbaeced9
39 changed files with 1299 additions and 295 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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...).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(
|
||||
<CategorySelector
|
||||
{...baseProps}
|
||||
value='Operations'
|
||||
/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Operations')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show existing categories as options', async () => {
|
||||
renderWithContext(<CategorySelector {...baseProps}/>, baseState);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.click(input);
|
||||
|
||||
expect(screen.getByText('Operations')).toBeInTheDocument();
|
||||
expect(screen.getByText('Intel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onChange when a category is selected', async () => {
|
||||
renderWithContext(<CategorySelector {...baseProps}/>, baseState);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.click(input);
|
||||
await userEvent.click(screen.getByText('Operations'));
|
||||
|
||||
expect(baseProps.onChange).toHaveBeenCalledWith('Operations');
|
||||
});
|
||||
|
||||
it('should be disabled when disabled prop is true', () => {
|
||||
const {container} = renderWithContext(
|
||||
<CategorySelector
|
||||
{...baseProps}
|
||||
disabled={true}
|
||||
/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
const selectControl = container.querySelector('.CategorySelector__control--is-disabled');
|
||||
expect(selectControl).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show create option for single character input', async () => {
|
||||
renderWithContext(<CategorySelector {...baseProps}/>, baseState);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.click(input);
|
||||
await userEvent.type(input, 'N');
|
||||
|
||||
expect(screen.queryByText(/Create new category/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use default placeholder when not overridden', () => {
|
||||
renderWithContext(<CategorySelector {...baseProps}/>, baseState);
|
||||
|
||||
expect(screen.getByText('Choose a default category (optional)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use label and placeholder overrides when provided', async () => {
|
||||
renderWithContext(
|
||||
<CategorySelector
|
||||
{...baseProps}
|
||||
label='Custom label'
|
||||
placeholder='Custom placeholder'
|
||||
/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Custom placeholder')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
expect(screen.getByText('Custom label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render help text when helpText prop is not provided', () => {
|
||||
const {container} = renderWithContext(<CategorySelector {...baseProps}/>, baseState);
|
||||
|
||||
expect(container.querySelector('.Input___customMessage')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render help text when helpText prop is provided', () => {
|
||||
renderWithContext(
|
||||
<CategorySelector
|
||||
{...baseProps}
|
||||
helpText='Choose where new channels will appear in the sidebar'
|
||||
/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Choose where new channels will appear in the sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use options from injected getOptions', async () => {
|
||||
const injectedOptions = ['Alpha', 'Beta'];
|
||||
const getOptions = () => injectedOptions;
|
||||
renderWithContext(
|
||||
<CategorySelector
|
||||
{...baseProps}
|
||||
getOptions={getOptions}
|
||||
/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.click(input);
|
||||
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
import classNames from 'classnames';
|
||||
import React, {useState, useMemo, useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
import {shallowEqual, useSelector} from 'react-redux';
|
||||
import {components} from 'react-select';
|
||||
import type {ClearIndicatorProps, GroupBase, OptionProps, Options, OptionsOrGroups} from 'react-select';
|
||||
import CreatableSelect from 'react-select/creatable';
|
||||
|
|
@ -12,25 +12,28 @@ import CreatableSelect from 'react-select/creatable';
|
|||
import {FolderOutlineIcon, FolderPlusOutlineIcon} from '@mattermost/compass-icons/components';
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
import {getManagedCategoryMappings} from 'mattermost-redux/selectors/entities/channel_categories';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
|
||||
import './managed_category_selector.scss';
|
||||
import './category_selector.scss';
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
export type CategorySelectorProps = {
|
||||
value?: string;
|
||||
onChange: (categoryName: string | undefined) => void;
|
||||
getOptions: (state: GlobalState, teamId: string) => string[];
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
menuPortalTargetId?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const IndicatorsContainer = (props: any) => (
|
||||
<div className='ManagedCategorySelector__indicatorsContainer'>
|
||||
<div className='CategorySelector__indicatorsContainer'>
|
||||
<components.IndicatorsContainer {...props}/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -46,17 +49,17 @@ const DropdownIndicator = () => (
|
|||
);
|
||||
|
||||
const Control = (props: any) => (
|
||||
<div className='ManagedCategorySelector__controlContainer'>
|
||||
<div className='CategorySelector__controlContainer'>
|
||||
<components.Control {...props}/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ValueContainer = ({children, ...props}: any) => (
|
||||
<components.ValueContainer {...props}>
|
||||
<div className='ManagedCategorySelector__valueContainerInner'>
|
||||
<div className='CategorySelector__valueContainerInner'>
|
||||
<FolderOutlineIcon
|
||||
size={16}
|
||||
className='ManagedCategorySelector__folderIcon'
|
||||
className='CategorySelector__folderIcon'
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -70,7 +73,7 @@ const OptionComponent = (props: OptionProps<Option, false, GroupBase<Option>>) =
|
|||
|
||||
return (
|
||||
<div
|
||||
className={classNames('ManagedCategorySelector__option', {
|
||||
className={classNames('CategorySelector__option', {
|
||||
selected: props.isSelected,
|
||||
focused: props.isFocused,
|
||||
})}
|
||||
|
|
@ -83,17 +86,19 @@ const OptionComponent = (props: OptionProps<Option, false, GroupBase<Option>>) =
|
|||
);
|
||||
};
|
||||
|
||||
export default function ManagedCategorySelector({value, onChange, menuPortalTargetId, disabled}: Props) {
|
||||
export default function CategorySelector({value, onChange, getOptions, label, placeholder, helpText, menuPortalTargetId, disabled}: CategorySelectorProps) {
|
||||
const {formatMessage} = useIntl();
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
const teamId = useSelector(getCurrentTeamId);
|
||||
const managedMappings = useSelector((state: GlobalState) => getManagedCategoryMappings(state, teamId));
|
||||
const selectOptionNames = useCallback(
|
||||
(state: GlobalState) => getOptions(state, teamId ?? ''),
|
||||
[getOptions, teamId],
|
||||
);
|
||||
const optionNames = useSelector(selectOptionNames, shallowEqual);
|
||||
const options: Option[] = useMemo(() => {
|
||||
const uniqueNames = [...new Set(Object.values(managedMappings ?? []))];
|
||||
uniqueNames.sort((a, b) => a.localeCompare(b, undefined, {numeric: true}));
|
||||
return uniqueNames.map((name) => ({label: name, value: name}));
|
||||
}, [managedMappings]);
|
||||
return optionNames.map((name) => ({label: name, value: name}));
|
||||
}, [optionNames]);
|
||||
|
||||
const selectedOption: Option | null = value ? {label: value, value} : null;
|
||||
|
||||
|
|
@ -105,8 +110,8 @@ export default function ManagedCategorySelector({value, onChange, menuPortalTarg
|
|||
const formatCreateLabel = useCallback((inputValue: string) => {
|
||||
return (
|
||||
<>
|
||||
<span className='ManagedCategorySelector__createLabelPrefix'>
|
||||
{formatMessage({id: 'managed_category.create_new_prefix', defaultMessage: 'Create new category: '})}
|
||||
<span className='CategorySelector__createLabelPrefix'>
|
||||
{formatMessage({id: 'default_category.create_new_prefix', defaultMessage: 'Create new category: '})}
|
||||
</span>
|
||||
<span>{inputValue}</span>
|
||||
</>
|
||||
|
|
@ -133,11 +138,12 @@ export default function ManagedCategorySelector({value, onChange, menuPortalTarg
|
|||
}, []);
|
||||
|
||||
const portalTarget = menuPortalTargetId ? document.getElementById(menuPortalTargetId) : undefined;
|
||||
const legend = formatMessage({id: 'managed_category.label', defaultMessage: 'Managed category (optional)'});
|
||||
const legend = label ?? formatMessage({id: 'default_category.label', defaultMessage: 'Default category (optional)'});
|
||||
const placeholderText = placeholder ?? formatMessage({id: 'default_category.placeholder', defaultMessage: 'Choose a default category (optional)'});
|
||||
const showLegend = Boolean(focused || value);
|
||||
|
||||
return (
|
||||
<div className='ManagedCategorySelector Input_container'>
|
||||
<div className='CategorySelector Input_container'>
|
||||
<fieldset
|
||||
className={classNames('Input_fieldset', {
|
||||
Input_fieldset___legend: showLegend,
|
||||
|
|
@ -154,7 +160,7 @@ export default function ManagedCategorySelector({value, onChange, menuPortalTarg
|
|||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<CreatableSelect<Option>
|
||||
classNamePrefix='ManagedCategory'
|
||||
classNamePrefix='CategorySelector'
|
||||
className={classNames('Input', {Input__focus: showLegend})}
|
||||
components={{IndicatorsContainer, ClearIndicator, DropdownIndicator, Option: OptionComponent, Control, ValueContainer}}
|
||||
isClearable={true}
|
||||
|
|
@ -163,12 +169,17 @@ export default function ManagedCategorySelector({value, onChange, menuPortalTarg
|
|||
onChange={handleChange}
|
||||
formatCreateLabel={formatCreateLabel}
|
||||
isValidNewOption={isValidNewOption}
|
||||
placeholder={focused ? formatMessage({id: 'managed_category.placeholder_focused', defaultMessage: 'Select category or type a new one'}) : formatMessage({id: 'managed_category.placeholder', defaultMessage: 'Choose a managed category (optional)'})}
|
||||
placeholder={focused ? formatMessage({id: 'default_category.placeholder_focused', defaultMessage: 'Select category or type a new one'}) : placeholderText}
|
||||
menuPortalTarget={portalTarget ?? undefined}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
{helpText && (
|
||||
<div className='Input___customMessage Input___info'>
|
||||
<span>{helpText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useState, useEffect, useMemo} from 'react';
|
||||
import React, {useCallback, useState, useEffect, useMemo, useRef} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ import type {ServerError} from '@mattermost/types/errors';
|
|||
import {patchChannel, updateChannelPrivacy} from 'mattermost-redux/actions/channels';
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
import Permissions from 'mattermost-redux/constants/permissions';
|
||||
import {areManagedCategoriesEnabled, getChannelManagedCategoryName} from 'mattermost-redux/selectors/entities/channel_categories';
|
||||
import {areManagedCategoriesEnabled, getChannelManagedCategoryName, isChannelCategorySortingEnabled, makeGetSidebarCategoryNamesForTeam} from 'mattermost-redux/selectors/entities/channel_categories';
|
||||
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
|
||||
|
||||
import {
|
||||
|
|
@ -24,8 +24,8 @@ import {
|
|||
} from 'selectors/views/textbox';
|
||||
|
||||
import ConvertConfirmModal from 'components/admin_console/team_channel_settings/convert_confirm_modal';
|
||||
import CategorySelector from 'components/category_selector/category_selector';
|
||||
import ChannelNameFormField from 'components/channel_name_form_field/channel_name_form_field';
|
||||
import ManagedCategorySelector from 'components/channel_settings_modal/managed_category_selector';
|
||||
import type {TextboxElement} from 'components/textbox';
|
||||
import AdvancedTextbox from 'components/widgets/advanced_textbox/advanced_textbox';
|
||||
import SaveChangesPanel, {type SaveChangesPanelState} from 'components/widgets/modals/components/save_changes_panel';
|
||||
|
|
@ -50,6 +50,9 @@ function ChannelSettingsInfoTab({
|
|||
}: ChannelSettingsInfoTabProps) {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const getSidebarCategoryNamesForTeam = useRef(makeGetSidebarCategoryNamesForTeam());
|
||||
|
||||
const shouldShowPreviewPurpose = useSelector(showPreviewOnChannelSettingsPurposeModal);
|
||||
const shouldShowPreviewHeader = useSelector(showPreviewOnChannelSettingsHeaderModal);
|
||||
|
||||
|
|
@ -85,10 +88,13 @@ function ChannelSettingsInfoTab({
|
|||
});
|
||||
const canManageChannelRoles = useSelector((state: GlobalState) => haveIChannelPermission(state, channel.team_id, channel.id, Permissions.MANAGE_CHANNEL_ROLES));
|
||||
|
||||
const enableManagedCategories = useSelector(areManagedCategoriesEnabled);
|
||||
const showManagedCategorySelector = enableManagedCategories && !isDMorGroupChannel;
|
||||
const showDefaultCategorySelector = useSelector(isChannelCategorySortingEnabled);
|
||||
const showDefaultCategoryField = showDefaultCategorySelector && !isDMorGroupChannel;
|
||||
|
||||
const currentCategoryName = useSelector((state: GlobalState) => getChannelManagedCategoryName(state, channel.id));
|
||||
const enableManagedCategories = useSelector(areManagedCategoriesEnabled);
|
||||
const showManagedCategoryField = enableManagedCategories && !isDMorGroupChannel;
|
||||
|
||||
const currentManagedCategoryName = useSelector((state: GlobalState) => getChannelManagedCategoryName(state, channel.id));
|
||||
|
||||
// Must stay aligned with server `UpdateChannel` when an ABAC membership policy is enforced.
|
||||
const channelTypeLockedByMembershipPolicy = Boolean(channel.policy_enforced);
|
||||
|
|
@ -99,13 +105,21 @@ function ChannelSettingsInfoTab({
|
|||
}) :
|
||||
undefined;
|
||||
|
||||
const [managedCategoryName, setManagedCategoryName] = useState(currentCategoryName);
|
||||
const [serverCategoryName, setServerCategoryName] = useState(currentCategoryName);
|
||||
const [defaultCategoryName, setDefaultCategoryName] = useState<string | undefined>(channel.default_category_name);
|
||||
const [serverDefaultCategoryName, setServerDefaultCategoryName] = useState<string | undefined>(channel.default_category_name);
|
||||
|
||||
const [managedCategoryName, setManagedCategoryName] = useState(currentManagedCategoryName);
|
||||
const [serverManagedCategoryName, setServerManagedCategoryName] = useState(currentManagedCategoryName);
|
||||
|
||||
useEffect(() => {
|
||||
setManagedCategoryName(currentCategoryName);
|
||||
setServerCategoryName(currentCategoryName);
|
||||
}, [currentCategoryName]);
|
||||
setDefaultCategoryName(channel.default_category_name);
|
||||
setServerDefaultCategoryName(channel.default_category_name);
|
||||
}, [channel.id, channel.default_category_name]);
|
||||
|
||||
useEffect(() => {
|
||||
setManagedCategoryName(currentManagedCategoryName);
|
||||
setServerManagedCategoryName(currentManagedCategoryName);
|
||||
}, [currentManagedCategoryName]);
|
||||
|
||||
// Constants
|
||||
const HEADER_MAX_LENGTH = 1024;
|
||||
|
|
@ -153,11 +167,12 @@ function ChannelSettingsInfoTab({
|
|||
channelPurpose.trim() !== channel.purpose ||
|
||||
channelHeader.trim() !== channel.header ||
|
||||
channelType !== channel.type ||
|
||||
managedCategoryName !== serverCategoryName
|
||||
(defaultCategoryName ?? '') !== (serverDefaultCategoryName ?? '') ||
|
||||
managedCategoryName !== serverManagedCategoryName
|
||||
) : false;
|
||||
|
||||
setAreThereUnsavedChanges?.(unsavedChanges);
|
||||
}, [channel, displayName, channelUrl, channelPurpose, channelHeader, channelType, managedCategoryName, serverCategoryName, setAreThereUnsavedChanges]);
|
||||
}, [channel, displayName, channelUrl, channelPurpose, channelHeader, channelType, defaultCategoryName, serverDefaultCategoryName, managedCategoryName, serverManagedCategoryName, setAreThereUnsavedChanges]);
|
||||
|
||||
const handleURLChange = useCallback((newURL: string) => {
|
||||
if (internalUrlError) {
|
||||
|
|
@ -288,7 +303,10 @@ function ChannelSettingsInfoTab({
|
|||
if (channelHeader.trim() !== channel.header) {
|
||||
updated.header = channelHeader.trim();
|
||||
}
|
||||
if (managedCategoryName !== serverCategoryName) {
|
||||
if ((defaultCategoryName ?? '') !== (serverDefaultCategoryName ?? '')) {
|
||||
updated.default_category_name = defaultCategoryName ?? '';
|
||||
}
|
||||
if (managedCategoryName !== serverManagedCategoryName) {
|
||||
updated.managed_category_name = managedCategoryName ?? '';
|
||||
}
|
||||
|
||||
|
|
@ -311,10 +329,11 @@ function ChannelSettingsInfoTab({
|
|||
setChannelPurpose(data?.purpose ?? updated.purpose ?? channel.purpose);
|
||||
}
|
||||
setChannelHeader(data?.header ?? updated.header ?? channel.header);
|
||||
setServerCategoryName(managedCategoryName);
|
||||
setServerDefaultCategoryName(defaultCategoryName);
|
||||
setServerManagedCategoryName(managedCategoryName);
|
||||
|
||||
return true;
|
||||
}, [channel, displayName, channelType, isDMorGroupChannel, channelUrl, channelPurpose, channelHeader, dispatch, formatMessage, handleServerError, managedCategoryName, serverCategoryName]);
|
||||
}, [channel, displayName, channelType, isDMorGroupChannel, channelUrl, channelPurpose, channelHeader, dispatch, formatMessage, handleServerError, defaultCategoryName, serverDefaultCategoryName, managedCategoryName, serverManagedCategoryName]);
|
||||
|
||||
// Handle save changes panel actions
|
||||
const handleSaveChanges = useCallback(async () => {
|
||||
|
|
@ -355,7 +374,8 @@ function ChannelSettingsInfoTab({
|
|||
setChannelPurpose(channel?.purpose ?? '');
|
||||
setChannelHeader(channel?.header ?? '');
|
||||
setChannelType(channel?.type as ChannelType ?? Constants.OPEN_CHANNEL as ChannelType);
|
||||
setManagedCategoryName(serverCategoryName);
|
||||
setDefaultCategoryName(serverDefaultCategoryName);
|
||||
setManagedCategoryName(serverManagedCategoryName);
|
||||
|
||||
// Clear errors
|
||||
setUrlError('');
|
||||
|
|
@ -367,7 +387,7 @@ function ChannelSettingsInfoTab({
|
|||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
}, [channel, onCancel, serverCategoryName, setFormError]);
|
||||
}, [channel, onCancel, serverDefaultCategoryName, serverManagedCategoryName, setFormError]);
|
||||
|
||||
// Calculate if there are errors
|
||||
const hasErrors = Boolean(formError) ||
|
||||
|
|
@ -386,12 +406,13 @@ function ChannelSettingsInfoTab({
|
|||
unsavedChanges = unsavedChanges || channelUrl.trim() !== channel.name;
|
||||
unsavedChanges = unsavedChanges || channelPurpose.trim() !== channel.purpose;
|
||||
unsavedChanges = unsavedChanges || channelType !== channel.type;
|
||||
unsavedChanges = unsavedChanges || managedCategoryName !== serverCategoryName;
|
||||
unsavedChanges = unsavedChanges || (defaultCategoryName ?? '') !== (serverDefaultCategoryName ?? '');
|
||||
unsavedChanges = unsavedChanges || managedCategoryName !== serverManagedCategoryName;
|
||||
}
|
||||
}
|
||||
|
||||
return unsavedChanges || saveChangesPanelState === 'saved';
|
||||
}, [channel, isDMorGroupChannel, displayName, channelUrl, channelPurpose, channelHeader, channelType, saveChangesPanelState, managedCategoryName, serverCategoryName]);
|
||||
}, [channel, isDMorGroupChannel, displayName, channelUrl, channelPurpose, channelHeader, channelType, saveChangesPanelState, defaultCategoryName, serverDefaultCategoryName, managedCategoryName, serverManagedCategoryName]);
|
||||
|
||||
return (
|
||||
<div className='ChannelSettingsModal__infoTab'>
|
||||
|
|
@ -459,11 +480,25 @@ function ChannelSettingsInfoTab({
|
|||
onChange={handleChannelTypeChange}
|
||||
/>
|
||||
)}
|
||||
{/* Admin Sidebar Category Selector */}
|
||||
{showManagedCategorySelector && (
|
||||
<ManagedCategorySelector
|
||||
{/* Default Sidebar Category Selector */}
|
||||
{showDefaultCategoryField && (
|
||||
<CategorySelector
|
||||
value={defaultCategoryName}
|
||||
onChange={setDefaultCategoryName}
|
||||
getOptions={getSidebarCategoryNamesForTeam.current}
|
||||
menuPortalTargetId='channelSettingsModal'
|
||||
disabled={!canManageChannelProperties}
|
||||
helpText={formatMessage({id: 'default_category.help_text', defaultMessage: 'Sets the default sidebar category for users when they join the channel.'})}
|
||||
/>
|
||||
)}
|
||||
{/* Managed Sidebar Category Selector */}
|
||||
{showManagedCategoryField && (
|
||||
<CategorySelector
|
||||
value={managedCategoryName}
|
||||
onChange={setManagedCategoryName}
|
||||
getOptions={getSidebarCategoryNamesForTeam.current}
|
||||
label={formatMessage({id: 'managed_category.label', defaultMessage: 'Managed category (optional)'})}
|
||||
placeholder={formatMessage({id: 'managed_category.placeholder', defaultMessage: 'Choose a managed category (optional)'})}
|
||||
menuPortalTargetId='channelSettingsModal'
|
||||
disabled={!canManageChannelRoles}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
||||
|
||||
import ManagedCategorySelector from './managed_category_selector';
|
||||
|
||||
const baseState = {
|
||||
entities: {
|
||||
general: {
|
||||
config: {EnableManagedChannelCategories: 'true'},
|
||||
},
|
||||
teams: {
|
||||
currentTeamId: 'team1',
|
||||
},
|
||||
channelCategories: {
|
||||
byId: {},
|
||||
orderByTeam: {},
|
||||
managedCategoryMappings: {
|
||||
team1: {
|
||||
channel1: 'Operations',
|
||||
channel2: 'Operations',
|
||||
channel3: 'Intel',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('ManagedCategorySelector', () => {
|
||||
const baseProps = {
|
||||
value: '',
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
baseProps.onChange.mockClear();
|
||||
});
|
||||
|
||||
it('should render with the selected value', () => {
|
||||
renderWithContext(
|
||||
<ManagedCategorySelector
|
||||
{...baseProps}
|
||||
value='Operations'
|
||||
/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Operations')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show existing categories as options', async () => {
|
||||
renderWithContext(<ManagedCategorySelector {...baseProps}/>, baseState);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.click(input);
|
||||
|
||||
expect(screen.getByText('Operations')).toBeInTheDocument();
|
||||
expect(screen.getByText('Intel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onChange when a category is selected', async () => {
|
||||
renderWithContext(<ManagedCategorySelector {...baseProps}/>, baseState);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.click(input);
|
||||
await userEvent.click(screen.getByText('Operations'));
|
||||
|
||||
expect(baseProps.onChange).toHaveBeenCalledWith('Operations');
|
||||
});
|
||||
|
||||
it('should be disabled when disabled prop is true', () => {
|
||||
const {container} = renderWithContext(
|
||||
<ManagedCategorySelector
|
||||
{...baseProps}
|
||||
disabled={true}
|
||||
/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
const selectControl = container.querySelector('.ManagedCategory__control--is-disabled');
|
||||
expect(selectControl).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show create option for single character input', async () => {
|
||||
renderWithContext(<ManagedCategorySelector {...baseProps}/>, baseState);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.click(input);
|
||||
await userEvent.type(input, 'N');
|
||||
|
||||
expect(screen.queryByText(/Create new category/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React, {useCallback, useState} from 'react';
|
||||
import React, {useCallback, useRef, useState} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ import {setNewChannelWithBoardPreference} from 'mattermost-redux/actions/boards'
|
|||
import {createChannel} from 'mattermost-redux/actions/channels';
|
||||
import Permissions from 'mattermost-redux/constants/permissions';
|
||||
import Preferences from 'mattermost-redux/constants/preferences';
|
||||
import {areManagedCategoriesEnabled} from 'mattermost-redux/selectors/entities/channel_categories';
|
||||
import {areManagedCategoriesEnabled, isChannelCategorySortingEnabled, makeGetSidebarCategoryNamesForTeam} from 'mattermost-redux/selectors/entities/channel_categories';
|
||||
import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {haveICurrentChannelPermission} from 'mattermost-redux/selectors/entities/roles';
|
||||
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
|
||||
|
|
@ -24,8 +24,8 @@ import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
|
|||
import {switchToChannel} from 'actions/views/channel';
|
||||
import {closeModal} from 'actions/views/modals';
|
||||
|
||||
import CategorySelector from 'components/category_selector/category_selector';
|
||||
import ChannelNameFormField from 'components/channel_name_form_field/channel_name_form_field';
|
||||
import ManagedCategorySelector from 'components/channel_settings_modal/managed_category_selector';
|
||||
import Input from 'components/widgets/inputs/input/input';
|
||||
import PublicPrivateSelector from 'components/widgets/public-private-selector/public-private-selector';
|
||||
|
||||
|
|
@ -61,11 +61,14 @@ const NewChannelModal = () => {
|
|||
const intl = useIntl();
|
||||
const {formatMessage} = intl;
|
||||
|
||||
const getSidebarCategoryNamesForTeam = useRef(makeGetSidebarCategoryNamesForTeam());
|
||||
|
||||
const currentTeamId = useSelector(getCurrentTeam)?.id;
|
||||
|
||||
const canCreatePublicChannel = useSelector((state: GlobalState) => (currentTeamId ? haveICurrentChannelPermission(state, Permissions.CREATE_PUBLIC_CHANNEL) : false));
|
||||
const canCreatePrivateChannel = useSelector((state: GlobalState) => (currentTeamId ? haveICurrentChannelPermission(state, Permissions.CREATE_PRIVATE_CHANNEL) : false));
|
||||
const enableManagedCategories = useSelector(areManagedCategoriesEnabled);
|
||||
const showDefaultCategorySelector = useSelector(isChannelCategorySortingEnabled);
|
||||
const showManagedCategorySelector = useSelector(areManagedCategoriesEnabled);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [type, setType] = useState(getChannelTypeFromPermissions(canCreatePublicChannel, canCreatePrivateChannel));
|
||||
|
|
@ -76,6 +79,7 @@ const NewChannelModal = () => {
|
|||
const [purposeError, setPurposeError] = useState('');
|
||||
const [serverError, setServerError] = useState('');
|
||||
const [channelInputError, setChannelInputError] = useState(false);
|
||||
const [defaultCategoryName, setDefaultCategoryName] = useState<string | undefined>(undefined);
|
||||
const [managedCategoryName, setManagedCategoryName] = useState<string | undefined>(undefined);
|
||||
|
||||
// create a board along with the channel
|
||||
|
|
@ -111,6 +115,7 @@ const NewChannelModal = () => {
|
|||
last_root_post_at: 0,
|
||||
scheme_id: '',
|
||||
update_at: 0,
|
||||
default_category_name: defaultCategoryName,
|
||||
managed_category_name: managedCategoryName,
|
||||
};
|
||||
|
||||
|
|
@ -288,11 +293,25 @@ const NewChannelModal = () => {
|
|||
}}
|
||||
onChange={handleOnTypeChange}
|
||||
/>
|
||||
{enableManagedCategories && (
|
||||
{showDefaultCategorySelector && (
|
||||
<div className='new-channel-modal-managed-category'>
|
||||
<ManagedCategorySelector
|
||||
<CategorySelector
|
||||
value={defaultCategoryName}
|
||||
onChange={setDefaultCategoryName}
|
||||
getOptions={getSidebarCategoryNamesForTeam.current}
|
||||
menuPortalTargetId='new-channel-modal'
|
||||
helpText={formatMessage({id: 'default_category.help_text', defaultMessage: 'Sets the default sidebar category for users when they join the channel.'})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showManagedCategorySelector && (
|
||||
<div className='new-channel-modal-managed-category'>
|
||||
<CategorySelector
|
||||
value={managedCategoryName}
|
||||
onChange={setManagedCategoryName}
|
||||
getOptions={getSidebarCategoryNamesForTeam.current}
|
||||
label={formatMessage({id: 'managed_category.label', defaultMessage: 'Managed category (optional)'})}
|
||||
placeholder={formatMessage({id: 'managed_category.placeholder', defaultMessage: 'Choose a managed category (optional)'})}
|
||||
menuPortalTargetId='new-channel-modal'
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1117,8 +1117,6 @@
|
|||
"admin.experimental.allowCustomThemes.title": "Allow Custom Themes:",
|
||||
"admin.experimental.allowedEmailDomain.desc": "(Optional) When set, users must have an email ending in this domain to move threads. Multiple domains can be specified by separating them with commas.",
|
||||
"admin.experimental.allowedEmailDomain.title": "Allowed Email Domain",
|
||||
"admin.experimental.channelCategorySorting.desc": "When true, channels will be automatically sorted into categories based on their names using a \"/\" delimiter.",
|
||||
"admin.experimental.channelCategorySorting.title": "Channel Category Sorting:",
|
||||
"admin.experimental.collapsedThreads.always_on": "Always On",
|
||||
"admin.experimental.collapsedThreads.default_off": "Enabled (Default Off)",
|
||||
"admin.experimental.collapsedThreads.default_on": "Enabled (Default On)",
|
||||
|
|
@ -3345,6 +3343,8 @@
|
|||
"admin.team.deleteAccountTitle": "Delete Account Link:",
|
||||
"admin.team.emailInvitationsDescription": "When true users can invite others to the system using email.",
|
||||
"admin.team.emailInvitationsTitle": "Enable Email Invitations: ",
|
||||
"admin.team.enableChannelCategorySortingDescription": "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.",
|
||||
"admin.team.enableChannelCategorySortingTitle": "Channel category sorting:",
|
||||
"admin.team.enableJoinLeaveMessageDescription": "Choose the default configuration of system messages displayed when users join or leave channels. Users can override this default by configuring Join/Leave messages in Account Settings > Advanced.",
|
||||
"admin.team.enableJoinLeaveMessageTitle": "Enable join/leave messages by default:",
|
||||
"admin.team.invalidateEmailInvitesDescription": "This will invalidate active email invitations that have not been accepted by the user. By default email invitations expire after 48 hours.",
|
||||
|
|
@ -3353,8 +3353,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:",
|
||||
|
|
@ -4462,6 +4460,11 @@
|
|||
"deactivate_member_modal.desc.for_users_with_bot_accounts3": "Bot accounts they manage will be disabled along with their integrations. To enable them again, go to <linkBots>Integrations > Bot Accounts</linkBots>. <linkDocumentation>Learn more about bot accounts</linkDocumentation>.",
|
||||
"deactivate_member_modal.sso_warning": "You must also deactivate this user in the SSO provider or they will be reactivated on next login or sync.",
|
||||
"deactivate_member_modal.title": "Deactivate {username}",
|
||||
"default_category.create_new_prefix": "Create new category: ",
|
||||
"default_category.help_text": "Sets the default sidebar category for users when they join the channel.",
|
||||
"default_category.label": "Default category (optional)",
|
||||
"default_category.placeholder": "Choose a default category (optional)",
|
||||
"default_category.placeholder_focused": "Select category or type a new one",
|
||||
"delete_category_modal.delete": "Delete",
|
||||
"delete_category_modal.deleteCategory": "Delete this category?",
|
||||
"delete_category_modal.helpText": "Channels in <b>{category_name}</b> will move back to the Channels and Direct messages categories. You're not removed from any channels.",
|
||||
|
|
@ -5353,10 +5356,8 @@
|
|||
"login.verified": "Email Verified",
|
||||
"manage_channel_groups_modal.search_placeholder": "Search groups",
|
||||
"manage_team_groups_modal.search_placeholder": "Search groups",
|
||||
"managed_category.create_new_prefix": "Create new category: ",
|
||||
"managed_category.label": "Managed category (optional)",
|
||||
"managed_category.placeholder": "Choose a managed category (optional)",
|
||||
"managed_category.placeholder_focused": "Select category or type a new one",
|
||||
"mark_all_threads_as_read_modal.confirm": "Mark all as read",
|
||||
"mark_all_threads_as_read_modal.description": "All your threads will be marked as read, with unread and mention badges cleared. Do you want to continue?",
|
||||
"mark_all_threads_as_read_modal.title": "Mark all your threads as read",
|
||||
|
|
|
|||
|
|
@ -525,6 +525,32 @@ describe('addChannelToInitialCategory', () => {
|
|||
expect(categoriesById.dmCategory2.channel_ids).toEqual(['gmChannel', 'dmChannel1', 'dmChannel2']);
|
||||
expect(categoriesById.channelsCategory1.channel_ids).toEqual(['publicChannel1', 'gmChannel']);
|
||||
});
|
||||
|
||||
test('should not add channel to Channels category when default_category_name is set', async () => {
|
||||
const channelsCategory1 = {id: 'channelsCategory1', team_id: 'team1', type: CategoryTypes.CHANNELS, channel_ids: ['publicChannel1', 'privateChannel1']};
|
||||
|
||||
const store = configureStore({
|
||||
entities: {
|
||||
channelCategories: {
|
||||
byId: {
|
||||
channelsCategory1,
|
||||
},
|
||||
orderByTeam: {
|
||||
team1: ['channelsCategory1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const newChannel = {id: 'newChannel', type: General.OPEN_CHANNEL, team_id: 'team1', default_category_name: 'My Category'};
|
||||
|
||||
const result = await store.dispatch(Actions.addChannelToInitialCategory(newChannel));
|
||||
|
||||
expect(result).toEqual({data: false});
|
||||
|
||||
const categoriesById = getAllCategoriesByIds(store.getState());
|
||||
expect(categoriesById.channelsCategory1.channel_ids).toEqual(['publicChannel1', 'privateChannel1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addChannelToCategory', () => {
|
||||
|
|
|
|||
|
|
@ -199,6 +199,10 @@ export function addChannelToInitialCategory(channel: Channel, setOnServer = fals
|
|||
dispatch(fetchChannelManagedCategoryMapping(channel));
|
||||
}
|
||||
|
||||
if (channel.default_category_name) {
|
||||
return {data: false};
|
||||
}
|
||||
|
||||
// Add the new channel to the Channels category on the channel's team
|
||||
if (categories.some((category) => category.channel_ids.some((channelId) => channelId === channel.id))) {
|
||||
return {data: false};
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
@ -1697,3 +1697,235 @@ describe('makeGetCategoriesForTeam (merged)', () => {
|
|||
expect(result[2].channel_ids).toEqual(nonManagedCategory2.channel_ids);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isChannelCategorySortingEnabled', () => {
|
||||
test('should return true when EnableChannelCategorySorting config is "true"', () => {
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
config: {EnableChannelCategorySorting: 'true'},
|
||||
},
|
||||
},
|
||||
} as unknown as GlobalState;
|
||||
|
||||
expect(Selectors.isChannelCategorySortingEnabled(state)).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false when EnableChannelCategorySorting config is "false"', () => {
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
config: {EnableChannelCategorySorting: 'false'},
|
||||
},
|
||||
},
|
||||
} as unknown as GlobalState;
|
||||
|
||||
expect(Selectors.isChannelCategorySortingEnabled(state)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false when EnableChannelCategorySorting config is missing', () => {
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
} as unknown as GlobalState;
|
||||
|
||||
expect(Selectors.isChannelCategorySortingEnabled(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeGetSidebarCategoryNamesForTeam', () => {
|
||||
const customB = {
|
||||
id: 'customB',
|
||||
team_id: 'team1',
|
||||
type: CategoryTypes.CUSTOM,
|
||||
display_name: 'Bravo',
|
||||
channel_ids: ['channel1'],
|
||||
sorting: CategorySorting.Default,
|
||||
user_id: 'user1',
|
||||
muted: false,
|
||||
collapsed: false,
|
||||
};
|
||||
const customA = {
|
||||
id: 'customA',
|
||||
team_id: 'team1',
|
||||
type: CategoryTypes.CUSTOM,
|
||||
display_name: 'Alpha',
|
||||
channel_ids: ['channel2'],
|
||||
sorting: CategorySorting.Default,
|
||||
user_id: 'user1',
|
||||
muted: false,
|
||||
collapsed: false,
|
||||
};
|
||||
const favoritesCategory = {
|
||||
id: 'favorites1',
|
||||
team_id: 'team1',
|
||||
type: CategoryTypes.FAVORITES,
|
||||
display_name: 'Favorites',
|
||||
channel_ids: ['channel3'],
|
||||
sorting: CategorySorting.Default,
|
||||
user_id: 'user1',
|
||||
muted: false,
|
||||
collapsed: false,
|
||||
};
|
||||
const channelsCategory = {
|
||||
id: 'channels1',
|
||||
team_id: 'team1',
|
||||
type: CategoryTypes.CHANNELS,
|
||||
display_name: 'Channels',
|
||||
channel_ids: ['channel4'],
|
||||
sorting: CategorySorting.Default,
|
||||
user_id: 'user1',
|
||||
muted: false,
|
||||
collapsed: false,
|
||||
};
|
||||
const dmCategory = {
|
||||
id: 'dm1',
|
||||
team_id: 'team1',
|
||||
type: CategoryTypes.DIRECT_MESSAGES,
|
||||
display_name: 'Direct Messages',
|
||||
channel_ids: [],
|
||||
sorting: CategorySorting.Default,
|
||||
user_id: 'user1',
|
||||
muted: false,
|
||||
collapsed: false,
|
||||
};
|
||||
|
||||
test('should return only CUSTOM category names sorted alphabetically by locale', () => {
|
||||
const getSidebarCategoryNamesForTeam = Selectors.makeGetSidebarCategoryNamesForTeam();
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
config: {FeatureFlagManagedChannelCategories: 'true'},
|
||||
},
|
||||
channelCategories: {
|
||||
byId: {
|
||||
favorites1: favoritesCategory,
|
||||
channels1: channelsCategory,
|
||||
dm1: dmCategory,
|
||||
customB,
|
||||
customA,
|
||||
},
|
||||
orderByTeam: {team1: ['favorites1', 'customB', 'channels1', 'customA', 'dm1']},
|
||||
managedCategoryMappings: {},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'user1',
|
||||
profiles: {},
|
||||
},
|
||||
},
|
||||
} as unknown as GlobalState;
|
||||
|
||||
expect(getSidebarCategoryNamesForTeam(state, 'team1')).toEqual(['Alpha', 'Bravo']);
|
||||
});
|
||||
|
||||
test('should include MANAGED categories and exclude FAVORITES/CHANNELS/DIRECT_MESSAGES', () => {
|
||||
const getSidebarCategoryNamesForTeam = Selectors.makeGetSidebarCategoryNamesForTeam();
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
config: {FeatureFlagManagedChannelCategories: 'true'},
|
||||
},
|
||||
channels: {
|
||||
channels: {
|
||||
channel4: {id: 'channel4', team_id: 'team1'},
|
||||
},
|
||||
},
|
||||
channelCategories: {
|
||||
byId: {
|
||||
favorites1: favoritesCategory,
|
||||
channels1: channelsCategory,
|
||||
dm1: dmCategory,
|
||||
customA,
|
||||
},
|
||||
orderByTeam: {team1: ['favorites1', 'customA', 'channels1', 'dm1']},
|
||||
managedCategoryMappings: {
|
||||
team1: {
|
||||
channel4: 'Operations',
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'user1',
|
||||
profiles: {},
|
||||
},
|
||||
},
|
||||
} as unknown as GlobalState;
|
||||
|
||||
expect(getSidebarCategoryNamesForTeam(state, 'team1')).toEqual(['Alpha', 'Operations']);
|
||||
});
|
||||
|
||||
test('should deduplicate names that appear in both CUSTOM and MANAGED categories', () => {
|
||||
const getSidebarCategoryNamesForTeam = Selectors.makeGetSidebarCategoryNamesForTeam();
|
||||
|
||||
const customAlpha = {
|
||||
...customA,
|
||||
display_name: 'Operations',
|
||||
};
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
config: {FeatureFlagManagedChannelCategories: 'true'},
|
||||
},
|
||||
channels: {
|
||||
channels: {
|
||||
channel4: {id: 'channel4', team_id: 'team1'},
|
||||
},
|
||||
},
|
||||
channelCategories: {
|
||||
byId: {
|
||||
favorites1: favoritesCategory,
|
||||
channels1: channelsCategory,
|
||||
dm1: dmCategory,
|
||||
customA: customAlpha,
|
||||
},
|
||||
orderByTeam: {team1: ['favorites1', 'customA', 'channels1', 'dm1']},
|
||||
managedCategoryMappings: {
|
||||
team1: {
|
||||
channel4: 'Operations',
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'user1',
|
||||
profiles: {},
|
||||
},
|
||||
},
|
||||
} as unknown as GlobalState;
|
||||
|
||||
expect(getSidebarCategoryNamesForTeam(state, 'team1')).toEqual(['Operations']);
|
||||
});
|
||||
|
||||
test('should sort numerically when locale is set on user', () => {
|
||||
const getSidebarCategoryNamesForTeam = Selectors.makeGetSidebarCategoryNamesForTeam();
|
||||
|
||||
const cat10 = {...customA, id: 'cat10', display_name: 'Item 10'};
|
||||
const cat2 = {...customA, id: 'cat2', display_name: 'Item 2'};
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
config: {FeatureFlagManagedChannelCategories: 'true'},
|
||||
},
|
||||
channelCategories: {
|
||||
byId: {cat10, cat2},
|
||||
orderByTeam: {team1: ['cat10', 'cat2']},
|
||||
managedCategoryMappings: {},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'user1',
|
||||
profiles: {
|
||||
user1: {id: 'user1', locale: 'en'},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as GlobalState;
|
||||
|
||||
expect(getSidebarCategoryNamesForTeam(state, 'team1')).toEqual(['Item 2', 'Item 10']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -475,7 +475,11 @@ function isUnreadChannel(
|
|||
}
|
||||
|
||||
export function areManagedCategoriesEnabled(state: GlobalState): boolean {
|
||||
return getConfig(state).EnableManagedChannelCategories === 'true';
|
||||
return getConfig(state).FeatureFlagManagedChannelCategories === 'true';
|
||||
}
|
||||
|
||||
export function isChannelCategorySortingEnabled(state: GlobalState): boolean {
|
||||
return getConfig(state).EnableChannelCategorySorting === 'true';
|
||||
}
|
||||
|
||||
export function getManagedCategoryMappings(state: GlobalState, teamId: string): Record<string, string> | undefined {
|
||||
|
|
@ -538,6 +542,24 @@ export function makeGetCategoriesForTeam(): (state: GlobalState, teamId: string)
|
|||
);
|
||||
}
|
||||
|
||||
export function makeGetSidebarCategoryNamesForTeam(): (state: GlobalState, teamId: string) => string[] {
|
||||
const getCategoriesForTeam = makeGetCategoriesForTeam();
|
||||
|
||||
return createSelector(
|
||||
'makeGetSidebarCategoryNamesForTeam',
|
||||
(state: GlobalState, teamId: string) => getCategoriesForTeam(state, teamId),
|
||||
(state: GlobalState) => getCurrentUserLocale(state),
|
||||
(categories, locale) => {
|
||||
const names = categories.
|
||||
filter((c) => c.type === CategoryTypes.CUSTOM || c.type === CategoryTypes.MANAGED).
|
||||
map((c) => c.display_name);
|
||||
const unique = [...new Set(names)];
|
||||
unique.sort((a, b) => a.localeCompare(b, locale, {numeric: true}));
|
||||
return unique;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function makeGetManagedCategoriesForTeam(): (state: GlobalState, teamId: string) => ChannelCategory[] {
|
||||
return createSelector(
|
||||
'makeGetManagedCategoriesForTeam',
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export type ClientConfig = {
|
|||
EnableExperimentalLocales: string;
|
||||
EnableUserStatuses: string;
|
||||
EnableLastActiveTime: string;
|
||||
EnableManagedChannelCategories: string;
|
||||
EnableChannelCategorySorting: string;
|
||||
EnableTimedDND: string;
|
||||
EnableCrossTeamSearch: 'true' | 'false';
|
||||
EnableCustomTermsOfService: string;
|
||||
|
|
@ -117,7 +117,6 @@ export type ClientConfig = {
|
|||
EnableUserDeactivation: string;
|
||||
EnableUserTypingMessages: string;
|
||||
EnforceMultifactorAuthentication: string;
|
||||
ExperimentalChannelCategorySorting: string;
|
||||
ExperimentalEnableAuthenticationTransfer: string;
|
||||
ExperimentalEnableAutomaticReplies: string;
|
||||
ExperimentalEnableDefaultChannelLeaveJoinMessages: string;
|
||||
|
|
@ -133,6 +132,7 @@ export type ClientConfig = {
|
|||
FeatureFlagWebSocketEventScope: string;
|
||||
FeatureFlagInteractiveDialogAppsForm: string;
|
||||
FeatureFlagContentFlagging: string;
|
||||
FeatureFlagManagedChannelCategories: string;
|
||||
|
||||
ForgotPasswordLink: string;
|
||||
GiphySdkKey: string;
|
||||
|
|
@ -468,6 +468,7 @@ export type TeamSettings = {
|
|||
ExperimentalDefaultChannels: string[];
|
||||
EnableLastActiveTime: boolean;
|
||||
EnableJoinLeaveMessageByDefault: boolean;
|
||||
EnableChannelCategorySorting: boolean;
|
||||
};
|
||||
|
||||
export type ClientRequirements = {
|
||||
|
|
@ -870,7 +871,6 @@ export type ExperimentalSettings = {
|
|||
DisableWakeUpReconnectHandler: boolean;
|
||||
UsersStatusAndProfileFetchingPollIntervalMilliseconds: number;
|
||||
YoutubeReferrerPolicy: boolean;
|
||||
ExperimentalChannelCategorySorting: boolean;
|
||||
EnableWatermark: boolean;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue