MM61173 - settings modal base creation (#30338)

* MM-61173 - channel settings modal: base modal, initial commit, file creation and base component

* new enhancements to the base modal creation

* revert changes on textbox_links and edit channel header

* fix types and add back unintentioned deleted value

* add the preview textbox component

* extract logic for info tab into its own component

* add the purpose input to the window

* move other component logic to its own component and code clean up

* ability to update channel type

* more advances on the archive channel tab

* fix unit test in textbox

* fix translations

* do not show the archive modal in default channel

* fix issue with url editor not being resetted on undo action

* adjust text and styling for the header and purpose inputs

* remove textboxlinks and use button eye icon

* adjust test and preview button style

* add unit test to channel patch

* move logic from parent modal to info tab component

* fix border issues and focus back to preview textareas

* prevent saving changes when pressing enter when selecting an icon

* enhance input component to cover limits validations and enhances tests

* set default error message for save changes panel

* add props to provide custom value to the buttons

* remove channel input errors on reset button click

* create new component settings textbox

* rename component to advanced textbox and add unit tests

* styling of the info tab and add error state to advanced textbox

* add logic to prevent tab switch with unsaved changes

* adjust url error logic and code clean up

* code clean up and enhance comments

* add char min length to advanced textbox logic

* add the channel settings modal to the new menu

* add new test files and fix reset error

* remove unused error variables

* adjust translations and remove unncesary import

* enhance permissions for archive channels and manage channel settings

* Adjust permission tree so channel admins can convert from private to public

* enhance the test suit around channel conversion type

* fix some e2e tests and solve channel input name issue

* fix unit test by interacting first with the input element

* adjust e2e tests to channel settings modal changes

* remove commented tests and implement pr feedback

* adjust more pr feedback to the code

* more pr feedback enhancements

* further enhancements to tab navigation, and adjust more e2e tests

* remove unused components and fix e2e tests

* revert unnecessary permissions changes

* Add name label to textboxes

* adjust e2e and unit tests

* revert min lenght change value and adjust tests and snapshots

* Channel banner settings (#30721)

* Added channel banner setting header

* Updated section styling

* handled animation

* handled min and max lengths

* cleanup

* color change fix

* general improvements

* Fixed API test

* removed unused param className

* added e2e tests

* test: add channel settings configuration tab test file

* Based on the context, here's a concise commit message for this change:

feat: Add comprehensive tests for ChannelSettingsConfigurationTab

* added some more tests

* CI

* reverted package-lock.json changes in Playwright

* remove extra border from advaced textbox

* adjust styling for name label in advance texbox and restart preview state on modal close

* sync package.lock in playwright

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Harshil Sharma <harshilsharma63@gmail.com>
Co-authored-by: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com>
This commit is contained in:
Pablo Vélez 2025-04-23 12:49:54 +02:00 committed by GitHub
parent f0dfe1c49f
commit 6ae0efd285
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 7479 additions and 2464 deletions

View file

@ -88,12 +88,14 @@ describe('Verify Accessibility Support in different sections in Settings and Pro
});
it('MM-T1465_1 Verify Label & Tab behavior in section links', () => {
// * Verify aria-label and tab support in section of Account settings modal
// * Verify tab selection and keyboard navigation in Account settings modal
cy.uiOpenProfileModal('Profile Settings');
cy.findByRole('tab', {name: 'profile settings'}).should('be.visible').focus().should('be.focused');
['profile settings', 'security'].forEach((text) => {
// * Verify aria-label on each tab and it supports navigating to the next tab with arrow keys
cy.focused().should('have.attr', 'aria-label', text).type('{downarrow}');
// * Verify each tab is correctly selected and supports navigating to the next tab with arrow keys
cy.findByRole('tab', {name: text}).
should('have.attr', 'aria-selected', 'true').
type('{downarrow}');
});
cy.uiClose();
@ -101,8 +103,10 @@ describe('Verify Accessibility Support in different sections in Settings and Pro
cy.uiOpenSettingsModal();
cy.findByRole('tab', {name: 'notifications'}).should('be.visible').focus().should('be.focused');
['notifications', 'display', 'sidebar', 'advanced'].forEach((text) => {
// * Verify aria-label on each tab and it supports navigating to the next tab with arrow keys
cy.focused().should('have.attr', 'aria-label', text).type('{downarrow}');
// * Verify each tab is correctly selected and supports navigating to the next tab with arrow keys
cy.findByRole('tab', {name: text}).
should('have.attr', 'aria-selected', 'true').
type('{downarrow}');
});
});

View file

@ -29,30 +29,39 @@ describe('Channel Settings - Channel Header', () => {
// # Visit the newly created channel
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Click on the channel name in the channel header to open the channel menu options
cy.get(`[aria-label="${channel.display_name.split('-').join(' ').toLowerCase()} channel menu"]`).click();
// # Select the "Edit Channel Header" option from the dropdown
cy.findByText('Channel Settings').should('be.visible').trigger('mouseover');
cy.findByText('Edit Channel Header').click();
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// * Verify modal is open
cy.get('.ChannelSettingsModal').should('be.visible');
cy.get('#genericModalLabel').should('contain', 'Channel Settings');
// # Type something in the header edit box
cy.get('textarea[placeholder="Edit the Channel Header..."]').clear().type('This is the new header content');
cy.get('#channel_settings_header_textbox').clear().type('This is the new header content');
// * Verify the "Preview" button exists
cy.findByText('Preview').should('be.visible');
// * Verify the "Preview" button exists and is not active
cy.get('#channel_settings_header_textbox').
parents('.AdvancedTextbox').
find('#PreviewInputTextButton').
should('not.have.class', 'active');
// * Verify that before hitting the preview button, the style on the textbox is `display: block`
cy.get('textarea[placeholder="Edit the Channel Header..."]').should('have.css', 'display', 'block');
cy.get('#channel_settings_header_textbox').should('have.css', 'display', 'block');
// # Click the "Preview" button
cy.findByText('Preview').click();
// * Verify the "Preview" button label has changed to "Edit"
cy.findByText('Edit').should('be.visible');
cy.get('#channel_settings_header_textbox').
parents('.AdvancedTextbox').
find('#PreviewInputTextButton').click();
// * Verify that the display is now none on the textbox element
cy.get('textarea[placeholder="Edit the Channel Header..."]').should('have.css', 'display', 'none');
cy.get('#channel_settings_header_textbox').should('have.css', 'display', 'none');
// * Verify the "Preview" button has class active
cy.get('#channel_settings_header_textbox').
parents('.AdvancedTextbox').
find('#PreviewInputTextButton').
should('have.class', 'active');
});
});
});

View file

@ -0,0 +1,361 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Stage: @prod
// Group: @channels @channel @channel_settings
import {Team} from '@mattermost/types/teams';
import {Channel} from '@mattermost/types/channels';
import {UserProfile} from '@mattermost/types/users';
describe('Channel Settings Modal', () => {
let testTeam: Team;
let testUser: UserProfile;
let testChannel: Channel;
let originalTestChannel: Channel;
before(() => {
// Setup test data
cy.apiInitSetup().then(({team, user}) => {
testTeam = team;
testUser = user;
// Create a test channel
cy.apiCreateChannel(testTeam.id, 'test-channel', 'Test Channel').then(({channel}) => {
testChannel = channel;
originalTestChannel = {...channel};
cy.apiAddUserToChannel(channel.id, user.id);
});
cy.apiLogin(testUser);
});
});
beforeEach(() => {
// Visit the channel before each test
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
});
// 2DO: update tests ids once QA has defined the test plan and zephyr tests
it('MM-T1: Can open and close the channel settings modal', () => {
// # Open channel settings modal from channel header dropdown
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// * Verify modal is open
cy.get('.ChannelSettingsModal').should('be.visible');
cy.get('#genericModalLabel').should('contain', 'Channel Settings');
// # Close the modal
cy.get('.GenericModal .modal-header button[aria-label="Close"]').click();
// * Verify modal is closed
cy.get('.ChannelSettingsModal').should('not.exist');
});
it('MM-T2: Can navigate between tabs', () => {
// # Open channel settings modal
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// * Verify Info tab is active by default
cy.get('#infoButton').should('have.class', 'active');
cy.get('.ChannelSettingsModal__infoTab').should('be.visible');
// # Click on Archive tab
cy.get('#archiveButton').click();
// * Verify Archive tab is active
cy.get('#archiveButton').should('have.class', 'active');
cy.get('.ChannelSettingsModal__archiveTab').should('be.visible');
// # Click back on Info tab
cy.get('#infoButton').click();
// * Verify Info tab is active again
cy.get('#infoButton').should('have.class', 'active');
cy.get('.ChannelSettingsModal__infoTab').should('be.visible');
});
it('MM-T3: Can edit channel name and URL', () => {
// # Open channel settings modal
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// # Get original channel name, url and alias it
cy.get('#input_channel-settings-name').invoke('val').as('originalName');
cy.get('.url-input-label').invoke('val').as('originalUrl');
// # Edit channel name
cy.get('#input_channel-settings-name').clear().type('Updated Channel Name');
// * Verify URL is updated automatically
cy.get('.url-input-label').should('contain', 'updated-channel-name');
// # Click Save
cy.get('[data-testid="SaveChangesPanel__save-btn"]').click();
// * Verify changes are saved
cy.get('.SaveChangesPanel').should('contain', 'Settings saved');
// # Close the modal
cy.get('.GenericModal .modal-header button[aria-label="Close"]').click();
// * Verify channel header shows updated name
cy.get('#channelHeaderTitle').should('contain', 'Updated Channel Name');
// # Open channel settings modal to restore original values
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// * Verify the channel name is reset to the original value
cy.get('#input_channel-settings-name').clear().type(originalTestChannel.display_name);
// # Try to change URL to match the original url
cy.get('.url-input-button').click();
cy.get('.url-input-container input').clear().type(originalTestChannel.name);
cy.get('.url-input-container button.url-input-button').click();
// # Save changes
cy.get('[data-testid="SaveChangesPanel__save-btn"]').click();
// * Verify changes are saved
cy.get('.SaveChangesPanel').should('contain', 'Settings saved');
// # Close the modal
cy.get('.GenericModal .modal-header button[aria-label="Close"]').click();
// * Verify channel header shows updated name
cy.get('#channelHeaderTitle').should('contain', originalTestChannel.display_name);
});
it('MM-T4: Shows error for invalid channel name', () => {
// # Open channel settings modal
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// # Set empty channel name
cy.get('#input_channel-settings-name').clear();
// # Try Save changes
cy.get('[data-testid="SaveChangesPanel__save-btn"]').click();
// * Verify error is shown
cy.get('.Input_fieldset').should('have.class', 'Input_fieldset___error');
cy.get('.SaveChangesPanel').should('contain', 'There are errors in the form above');
});
it('MM-T6: Can edit channel purpose and header', () => {
// # Open channel settings modal
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// # Edit channel purpose
cy.get('#channel_settings_purpose_textbox').clear().type('This is a test purpose');
// # Edit channel header
cy.get('#channel_settings_header_textbox').clear().type('This is a test header');
// # Save changes
cy.get('[data-testid="SaveChangesPanel__save-btn"]').click();
// * Verify changes are saved
cy.get('.SaveChangesPanel').should('contain', 'Settings saved');
// # Close the modal
cy.get('.GenericModal .modal-header button[aria-label="Close"]').click();
// * Verify channel header shows updated header
cy.get('#channelHeaderDescription').should('contain', 'This is a test header');
});
it('MM-T7: Shows error when purpose exceeds character limit', () => {
// # Open channel settings modal
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// # Enter purpose that exceeds character limit (250 characters)
const longPurpose = 'a'.repeat(260);
cy.get('#channel_settings_purpose_textbox').clear().type(longPurpose);
// * Verify error is shown
cy.findAllByTestId('channel_settings_purpose_textbox').should('have.class', 'textarea--has-errors');
cy.get('.SaveChangesPanel').should('contain', 'There are errors in the form above');
});
it('MM-T8: Shows error when header exceeds character limit', () => {
// # Open channel settings modal
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// # Enter header that exceeds character limit (1024 characters)
const longHeader = 'a'.repeat(1050);
cy.findByTestId('channel_settings_header_textbox').clear().type(longHeader);
// * Verify error is shown
cy.findByTestId('channel_settings_header_textbox').should('have.class', 'textarea--has-errors');
cy.get('.SaveChangesPanel').should('contain', 'There are errors in the form above');
});
it('MM-T9: Can archive a channel and redirect to previous visited channel', () => {
// # Create a new channel for this test
cy.apiCreateChannel(testTeam.id, 'first-channel', 'First Channel').then(({channel: channel1}) => {
cy.apiCreateChannel(testTeam.id, 'second-channel', 'Second Channel').then(({channel}) => {
// # visit town square
cy.visit(`/${testTeam.name}/channels/${channel1.name}`);
// # visit just created channel
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Open channel settings modal
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// # Click on Archive tab
cy.get('#archiveButton').click();
// # Click Archive button
cy.get('#channelSettingsArchiveChannelButton').click();
// * Verify confirmation modal appears
cy.get('#archiveChannelConfirmModal').should('be.visible');
// # Confirm archive
cy.findByRole('button', {name: 'Confirm'}).click();
// * Verify redirect to Town Square
cy.url().should('include', channel1.name);
// * Verify channel is no longer in sidebar
cy.get('.SidebarChannel').contains('Archive Test').should('not.exist');
});
});
});
it('MM-T10: Warns when switching tabs with unsaved changes', () => {
// # Create a new channel for this test
cy.apiCreateChannel(testTeam.id, 'unsaved-test', 'Unsaved Test').then(({channel}) => {
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Open channel settings modal
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// # Make a change to the channel name
cy.get('#input_channel-settings-name').clear().type('Changed Name');
// # Try to switch to Archive tab
cy.get('#archiveButton').click();
// * Verify we're still on the Info tab due to unsaved changes
cy.get('#infoButton').should('have.class', 'active');
// * Verify save changes panel shows error
cy.get('.SaveChangesPanel').should('have.class', 'error');
});
});
// MM-T11: Can reset changes without saving
it('MM-T11: Can reset changes without saving', () => {
// # Create a new channel for this test
cy.apiCreateChannel(testTeam.id, 'reset-test', 'Reset Test').then(({channel}) => {
// # Visit the channel page using the channel name returned from the API
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Open channel settings modal
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// # Get original channel name and alias it
cy.get('#input_channel-settings-name').invoke('val').as('originalName');
// # Change the channel name
cy.get('#input_channel-settings-name').clear().type('Temporary Name');
// # Click the Reset button in the SaveChangesPanel
cy.get('.SaveChangesPanel button').contains('Reset').click();
// * Verify the channel name is reset to the original value
cy.get('@originalName').then((originalName) => {
cy.get('#input_channel-settings-name').should('have.value', originalName);
});
// * Verify the SaveChangesPanel is no longer visible
cy.get('.SaveChangesPanel').should('not.exist');
});
});
// MM-T12: Can preview purpose and header with markdown
it('MM-T12: Can preview purpose and header with markdown', () => {
// # Create a new channel for this test
cy.apiCreateChannel(testTeam.id, 'markdown-test', 'Markdown Test').then(({channel}) => {
// # Visit the channel page using the channel name returned from the API
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Open channel settings modal
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// # Add markdown to purpose
cy.get('#channel_settings_purpose_textbox').clear().type('This is **bold** and _italic_ text');
// # Click preview button for purpose (assumes preview button is inside the AdvancedTextbox container)
cy.get('#channel_settings_purpose_textbox').
parents('.AdvancedTextbox').
find('#PreviewInputTextButton').
click();
// * Verify that markdown is rendered in preview: check for bold and italic formatting
cy.get('.textbox-preview-area').
should('contain', 'This is').
find('strong').
should('contain', 'bold');
cy.get('.textbox-preview-area').
find('em').
should('contain', 'italic');
// # Add markdown to header
cy.get('#channel_settings_header_textbox').clear().type('Visit [Mattermost](https://mattermost.com)');
// # Click preview button for header
cy.get('#channel_settings_header_textbox').
parents('.AdvancedTextbox').
find('#PreviewInputTextButton').
click();
// * Verify that markdown is rendered in preview: check for a link with text "Mattermost"
cy.get('.textbox-preview-area').
should('contain', 'Visit').
find('a').
should('contain', 'Mattermost');
});
});
it('MM-T13: Validates URL when editing channel name', () => {
// # Create two channels to test URL conflict error
cy.apiCreateChannel(testTeam.id, 'first-channel', 'First Channel').then(({channel: channel1}) => {
cy.apiCreateChannel(testTeam.id, 'second-channel', 'Second Channel').then(({channel}) => {
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Open channel settings modal
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// # Try to change URL to match the first channel
cy.get('.url-input-button').click();
cy.get('.url-input-container input').clear().type(channel1.name);
cy.get('.url-input-container button.url-input-button').click();
// * Verify error is shown
cy.get('[data-testid="SaveChangesPanel__save-btn"]').click();
cy.get('.SaveChangesPanel').should('contain', 'There are errors in the form above');
});
});
});
});

View file

@ -37,22 +37,23 @@ describe('Channel Settings', () => {
// # Go to test channel
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Go to channel dropdown > Rename channel
cy.get('#channelHeaderTitle').click();
cy.findByText('Channel Settings').should('be.visible').trigger('mouseover');
cy.findByText('Rename Channel').click();
// # Go to channel dropdown > Channel Settings Modal
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// # Try to enter existing URL and save
cy.get('#channel_name').clear().type('town-square');
cy.get('#save-button').click();
cy.get('#input_channel-settings-name').clear().type('town-square');
cy.get('[data-testid="SaveChangesPanel__save-btn"]').click();
// # Error is displayed and URL is unchanged
cy.get('.has-error').should('be.visible').and('contain', 'A channel with that name already exists on the same team.');
cy.get('.SaveChangesPanel').should('contain', 'There are errors in the form above');
cy.get('.url-input-error').should('be.visible').and('contain.text', 'A channel with that name already exists on the same team.');
cy.url().should('include', `/${testTeam.name}/channels/${channel.name}`);
// # Enter a new URL and save
cy.get('#channel_name').clear().type('another-town-square');
cy.get('#save-button').click();
cy.get('#input_channel-settings-name').clear().type('another-town-square');
cy.get('[data-testid="SaveChangesPanel__save-btn"]').click();
// * URL is updated and no errors are displayed
cy.url().should('include', `/${testTeam.name}/channels/another-town-square`);

View file

@ -1,179 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Team} from '@mattermost/types/teams';
import {UserProfile} from '@mattermost/types/users';
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Stage: @prod
// Group: @channels @channel
describe('Channels', () => {
let testUser: UserProfile;
let testTeam: Team;
before(() => {
cy.apiInitSetup().then(({team, user}) => {
testUser = user;
testTeam = team;
});
});
it('MM-T3348 - Convert to private channel should only be shown to users with permission', () => {
// # Reset permissions to default
resetPermissionsToDefault();
// # Enable convert public channels to private for all users
enablePermission('all_users-public_channel-convert_public_channel_to_private-checkbox');
saveConfig();
// # Login as a regular user
cy.apiLogin(testUser);
// # Create new test channel
cy.apiCreateChannel(testTeam.id, 'channel-test', 'Channel').then(({channel}) => {
// # Go to test channel
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Click the channel header dropdown
cy.get('#channelHeaderTitle').click();
// open ChannelSettings submenu
cy.findByRole('menuitem', {name: 'Channel Settings'}).trigger('mouseover');
// * Channel convert to private should be visible and confirm
cy.get('#channelConvertToPrivate').should('be.visible').click();
cy.findByTestId('convertChannelConfirm').should('be.visible').click();
// # Click the channel header dropdown
cy.get('#channelHeaderTitle').click();
// open ChannelSettings submenu
cy.findByRole('menuitem', {name: 'Channel Settings'}).trigger('mouseover');
// * Channel convert to private should no longer be visible
cy.get('#channelConvertToPrivate').should('not.exist');
});
// # Reset permissions to default
resetPermissionsToDefault();
// # Login as a regular user
cy.apiLogin(testUser);
// # Create new test channel
cy.apiCreateChannel(testTeam.id, 'channel-test', 'Channel').then(({channel}) => {
// # Go to test channel
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Click the channel header dropdown
cy.get('#channelHeaderTitle').click();
// open ChannelSettings submenu
cy.findByRole('menuitem', {name: 'Channel Settings'}).trigger('mouseover');
// * Channel convert to private should no longer be visible
cy.get('#channelConvertToPrivate').should('not.exist');
});
// # Reset permissions to default
resetPermissionsToDefault();
// # Remove permission from team admins
removePermission('team_admin-public_channel-convert_public_channel_to_private-checkbox');
saveConfig();
// # Promote user to team admin
cy.apiUpdateTeamMemberSchemeRole(testTeam.id, testUser.id, {scheme_admin: true, scheme_user: true});
// # Login as the now team admin user
cy.apiLogin(testUser);
// # Create new test channel
cy.apiCreateChannel(testTeam.id, 'channel-test', 'Channel').then(({channel}) => {
// # Go to test channel
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Click the channel header dropdown
cy.get('#channelHeaderTitle').click();
// open ChannelSettings submenu
cy.findByRole('menuitem', {name: 'Channel Settings'}).trigger('mouseover');
// * Channel convert to private should not be visible
cy.get('#channelConvertToPrivate').should('not.exist');
});
// # Reset permissions to default
resetPermissionsToDefault();
// # Login as the team admin user
cy.apiLogin(testUser);
// # Create new test channel
cy.apiCreateChannel(testTeam.id, 'channel-test', 'Channel').then(({channel}) => {
// # Go to test channel
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Click the channel header dropdown
cy.get('#channelHeaderTitle').click();
// open ChannelSettings submenu
cy.findByRole('menuitem', {name: 'Channel Settings'}).trigger('mouseover');
// * Channel convert to private should be visible and confirm
cy.get('#channelConvertToPrivate').should('be.visible').click();
cy.findByTestId('convertChannelConfirm').should('be.visible').click();
// # Click the channel header dropdown
cy.get('#channelHeaderTitle').click();
// open ChannelSettings submenu
cy.findByRole('menuitem', {name: 'Channel Settings'}).trigger('mouseover');
// * Channel convert to private should no longer be visible
cy.get('#channelConvertToPrivate').should('not.exist');
});
});
});
const saveConfig = () => {
cy.get('#saveSetting').click();
cy.waitUntil(() => cy.get('#saveSetting').then((el) => {
return el[0].innerText === 'Save';
}));
};
const enablePermission = (permissionCheckBoxTestId) => {
cy.findByTestId(permissionCheckBoxTestId).then((el) => {
if (!el.hasClass('checked')) {
el.click();
}
});
};
const removePermission = (permissionCheckBoxTestId) => {
cy.findByTestId(permissionCheckBoxTestId).then((el) => {
if (el.hasClass('checked')) {
el.click();
}
});
};
const resetPermissionsToDefault = () => {
// # Login as sysadmin and navigate to system scheme page
cy.apiAdminLogin();
cy.visit('/admin_console/user_management/permissions/system_scheme');
// # Click reset to defaults and confirm
cy.findByTestId('resetPermissionsToDefault').click();
cy.get('#confirmModalButton').click();
// # Save
saveConfig();
};

View file

@ -0,0 +1,567 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Team} from '@mattermost/types/teams';
import {UserProfile} from '@mattermost/types/users';
import {Channel} from '@mattermost/types/channels';
import {getAdminAccount} from '../../../support/env';
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Stage: @prod
// Group: @channels @channel
describe('Channel Type Conversion (Public to Private Only)', () => {
let testUser: UserProfile;
let testTeam: Team;
const admin = getAdminAccount();
// Helper functions for permission management
const saveConfig = () => {
cy.waitUntil(() =>
cy.get('#saveSetting').then((button) => {
// Check if the button text is exactly "Save"
if (button.text().trim() === 'Save') {
button.click();
return true; // signals waitUntil that it succeeded
}
return false;
}),
);
};
const enablePermission = (permissionCheckBoxTestId: string) => {
cy.findByTestId(permissionCheckBoxTestId).then((el) => {
if (!el.hasClass('checked')) {
el.click();
}
});
};
const removePermission = (permissionCheckBoxTestId: string) => {
cy.findByTestId(permissionCheckBoxTestId).then((el) => {
if (el.hasClass('checked')) {
el.click();
}
});
};
const promoteToChannelAdmin = (userId, channelId, admin) => {
cy.externalRequest({
user: admin,
method: 'put',
path: `channels/${channelId}/members/${userId}/schemeRoles`,
data: {
scheme_user: true,
scheme_admin: true,
},
});
};
const resetPermissionsToDefault = () => {
// # Login as sysadmin and navigate to system scheme page
cy.apiAdminLogin();
cy.visit('/admin_console/user_management/permissions/system_scheme');
// # Click reset to defaults and confirm
cy.findByTestId('resetPermissionsToDefault').click();
cy.get('#confirmModalButton').click();
// # Save
saveConfig();
cy.wait(1000);
cy.visit('/admin_console/user_management/permissions/system_scheme');
};
// Helper functions for permission setup
const setupPermissions = (config: {
resetToDefault?: boolean;
publicToPrivate?: boolean;
removeFromTeamAdmin?: boolean;
}) => {
if (config.resetToDefault) {
resetPermissionsToDefault();
}
// Public to private conversion permissions
if (config.publicToPrivate) {
enablePermission('all_users-public_channel-convert_public_channel_to_private-checkbox');
} else if (config.publicToPrivate === false) {
removePermission('all_users-public_channel-convert_public_channel_to_private-checkbox');
}
// Remove public to private conversion from team admin if specified
if (config.removeFromTeamAdmin) {
removePermission('team_admin-public_channel-convert_public_channel_to_private-checkbox');
}
cy.wait(1000);
saveConfig();
};
// Helper functions for channel management
const createAndVisitPublicChannel = (teamName: string, channelId: string, displayName: string): Cypress.Chainable<Channel> => {
return cy.apiCreateChannel(testTeam.id, channelId, displayName).then(({channel}) => {
cy.apiAddUserToChannel(channel.id, testUser.id);
cy.visit(`/${teamName}/channels/${channel.name}`);
return cy.wrap(channel);
});
};
const createAndVisitPrivateChannel = (teamName: string, channelId: string, displayName: string): Cypress.Chainable<Channel> => {
return cy.apiCreateChannel(testTeam.id, channelId, displayName, 'P').then(({channel}) => {
cy.apiAddUserToChannel(channel.id, testUser.id);
cy.visit(`/${teamName}/channels/${channel.name}`);
return cy.wrap(channel);
});
};
const visitChannel = (teamName: string, channelName: string) => {
cy.visit(`/${teamName}/channels/${channelName}`);
};
// Helper functions for UI interaction
const openChannelSettingsModal = () => {
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
cy.get('.ChannelSettingsModal').should('be.visible');
};
const closeChannelSettingsModal = () => {
cy.get('.GenericModal .modal-header button[aria-label="Close"]').click();
cy.get('.ChannelSettingsModal').should('not.exist');
};
// Helper functions for UI interaction
const saveChannelSettings = () => {
cy.get('[data-testid="SaveChangesPanel__save-btn"]').click();
};
const verifySettingsSaved = () => {
cy.get('.SaveChangesPanel').should('contain', 'Settings saved');
};
// Helper functions for channel conversion
const convertChannelToPrivate = () => {
// Select private channel type
cy.get('#public-private-selector-button-P').click();
cy.get('#public-private-selector-button-P').should('have.class', 'selected');
// Save changes - this will trigger the confirmation modal
saveChannelSettings();
// Handle the confirmation modal that appears when converting from public to private
cy.get('#confirmModal').should('be.visible');
cy.get('#confirmModalButton').click();
// Verify settings were saved
verifySettingsSaved();
};
// Function kept for potential future use but not used in current tests
// since private to public conversion is no longer allowed
// const convertChannelToPublic = () => {
// cy.get('#public-private-selector-button-O').click();
// cy.get('#public-private-selector-button-O').should('have.class', 'selected');
// };
// Helper functions for verification
const verifyChannelIsPrivate = (channelName: string) => {
cy.get('.SidebarChannel').contains(channelName).parent().find('.icon-lock-outline').should('exist');
};
// Function kept for potential future use but not used in current tests
// since private to public conversion is no longer allowed
// const verifyChannelIsPublic = (channelName: string) => {
// cy.get('.SidebarChannel').contains(channelName).parent().find('.icon-lock-outline').should('not.exist');
// };
const verifyConversionOptionDisabled = (toPrivate = true) => {
if (toPrivate) {
// Wait for the UI to fully load and stabilize
cy.wait(500);
// Check if the button is disabled - it might have a disabled attribute or a disabled class
cy.get('#public-private-selector-button-P').then(($el) => {
const isDisabled = $el.hasClass('disabled') || $el.prop('disabled') === true || $el.attr('aria-disabled') === 'true';
expect(isDisabled).to.be.true;
});
} else {
// Wait for the UI to fully load and stabilize
cy.wait(500);
// Check if the button is disabled - it might have a disabled attribute or a disabled class
cy.get('#public-private-selector-button-O').then(($el) => {
const isDisabled = $el.hasClass('disabled') || $el.prop('disabled') === true || $el.attr('aria-disabled') === 'true';
expect(isDisabled).to.be.true;
});
}
};
const verifyConversionOptionEnabled = (toPrivate = true) => {
if (toPrivate) {
// Wait for the UI to fully load and stabilize
cy.wait(500);
// Check if the button is enabled - it should not have disabled attributes or classes
cy.get('#public-private-selector-button-P').then(($el) => {
const isDisabled = $el.hasClass('disabled') || $el.prop('disabled') === true || $el.attr('aria-disabled') === 'true';
expect(isDisabled).to.be.false;
});
} else {
// Wait for the UI to fully load and stabilize
cy.wait(500);
// Check if the button is enabled - it should not have disabled attributes or classes
cy.get('#public-private-selector-button-O').then(($el) => {
const isDisabled = $el.hasClass('disabled') || $el.prop('disabled') === true || $el.attr('aria-disabled') === 'true';
expect(isDisabled).to.be.false;
});
}
};
// Helper function to make user a channel admin
const makeUserChannelAdmin = (channelId: string, userId: string) => {
cy.apiAddUserToChannel(channelId, userId);
promoteToChannelAdmin(userId, channelId, admin);
};
// Helper function to make user a team admin
const makeUserTeamAdmin = (teamId: string, userId: string) => {
cy.apiUpdateTeamMemberSchemeRole(teamId, userId, {scheme_admin: true, scheme_user: true});
};
before(() => {
cy.apiInitSetup().then(({team, user}) => {
testUser = user;
testTeam = team;
});
});
beforeEach(() => {
// Reset to a clean state before each test
cy.apiLogin(testUser);
});
describe('Basic Conversion Functionality', () => {
it('MM-T3348-1 - Can convert a public channel to private', () => {
// # Setup permissions
setupPermissions({resetToDefault: true, publicToPrivate: true});
// # Create and visit a public channel
createAndVisitPublicChannel(testTeam.name, 'public-to-private', 'Public To Private').then((channel) => {
// # Open channel settings modal
openChannelSettingsModal();
// # Convert to private
convertChannelToPrivate();
// # Close the modal
closeChannelSettingsModal();
// * Verify channel is now private
verifyChannelIsPrivate(channel.display_name);
});
});
it('MM-T3348-5 - Cannot convert a private channel back to public', () => {
// # Setup permissions
setupPermissions({resetToDefault: true, publicToPrivate: true});
// # Create and visit a public channel
createAndVisitPublicChannel(testTeam.name, 'private-stays-private', 'Private Stays Private').then((channel) => {
// # Open channel settings modal
openChannelSettingsModal();
// # Convert to private
convertChannelToPrivate();
// # Close the modal
closeChannelSettingsModal();
// * Verify channel is now private
verifyChannelIsPrivate(channel.display_name);
// # Open channel settings modal again
openChannelSettingsModal();
// * Verify conversion option to public is disabled
verifyConversionOptionDisabled(false);
// # Close the modal
closeChannelSettingsModal();
});
});
});
describe('Role-Based Channel Type Conversion', () => {
it('MM-T3350-1 - System admin cannot convert a private channel to public', () => {
// # Reset permissions to default
resetPermissionsToDefault();
// # Login as system admin
cy.apiAdminLogin();
// # Create and visit a private channel
createAndVisitPrivateChannel(testTeam.name, 'sysadmin-private-stays-private', 'SysAdmin Private Channel').then(() => {
// # Open channel settings modal
openChannelSettingsModal();
// * Verify conversion option to public is disabled
verifyConversionOptionDisabled(false);
// # Close the modal
closeChannelSettingsModal();
});
});
it('MM-T3350-2 - Channel admin cannot convert a private channel to public', () => {
// # Reset permissions to default
setupPermissions({resetToDefault: true});
// # Login as regular user
cy.apiLogin(testUser);
// # Create and visit a private channel
createAndVisitPrivateChannel(testTeam.name, 'channel-admin-private', 'Channel Admin Private').then((channel) => {
// # Make test user a channel admin
makeUserChannelAdmin(channel.id, testUser.id);
// # Visit the channel again after role change
visitChannel(testTeam.name, channel.name);
// # Open channel settings modal
openChannelSettingsModal();
// * Verify conversion option to public is disabled
verifyConversionOptionDisabled(false);
// # Close the modal
closeChannelSettingsModal();
});
});
it('MM-T3350-3 - Team admin cannot convert a private channel to public', () => {
// # Reset permissions to default
setupPermissions({resetToDefault: true});
// # Make test user a team admin
makeUserTeamAdmin(testTeam.id, testUser.id);
// # Login as team admin
cy.apiLogin(testUser);
// # Create and visit a private channel
createAndVisitPrivateChannel(testTeam.name, 'team-admin-private', 'Team Admin Private').then(() => {
// # Open channel settings modal
openChannelSettingsModal();
// * Verify conversion option to public is disabled
verifyConversionOptionDisabled(false);
// # Close the modal
closeChannelSettingsModal();
});
});
it('MM-T3350-4 - Regular user cannot convert a private channel to public', () => {
// # Reset permissions to default
resetPermissionsToDefault();
// # Create a new regular user specifically for this test
cy.apiCreateUser().then(({user: regularUser}) => {
// # Add user to the team
cy.apiAddUserToTeam(testTeam.id, regularUser.id);
// # Have admin create a private channel
cy.apiAdminLogin();
cy.apiCreateChannel(testTeam.id, 'admin-created-private', 'Admin Created Private', 'P').then(({channel}) => {
// # Add the regular user to the channel (as a regular member, not admin)
cy.apiAddUserToChannel(channel.id, regularUser.id);
// # Login as the regular user
cy.apiLogin(regularUser);
// # Visit the channel
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Open channel settings modal
openChannelSettingsModal();
// * Verify conversion option is disabled
verifyConversionOptionDisabled(false);
// # Close the modal
closeChannelSettingsModal();
});
});
});
});
describe('Permission-Based Tests', () => {
it('MM-T3348-2 - Regular user without permission cannot convert public to private', () => {
// # Setup permissions - remove public to private conversion permission
setupPermissions({resetToDefault: true, publicToPrivate: false});
// # Create a new regular user specifically for this test
cy.apiCreateUser().then(({user: regularUser}) => {
// # Add user to the team
cy.apiAddUserToTeam(testTeam.id, regularUser.id);
// # Have admin create a public channel
cy.apiAdminLogin();
cy.apiCreateChannel(testTeam.id, 'admin-created-public', 'Admin Created Public').then(({channel}) => {
// # Add the regular user to the channel (as a regular member, not admin)
cy.apiAddUserToChannel(channel.id, regularUser.id);
// # Login as the regular user
cy.apiLogin(regularUser);
// # Visit the channel
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Open channel settings modal
openChannelSettingsModal();
// * Verify conversion option is disabled
verifyConversionOptionDisabled(true);
// # Close the modal
closeChannelSettingsModal();
});
});
});
it('MM-T3348-3 - Team admin can convert public to private by default', () => {
// # Setup permissions - reset to default
setupPermissions({resetToDefault: true});
// # Make test user a team admin
makeUserTeamAdmin(testTeam.id, testUser.id);
// # Create and visit a public channel
createAndVisitPublicChannel(testTeam.name, 'team-admin-convert', 'Team Admin Convert').then((channel) => {
// # Open channel settings modal
openChannelSettingsModal();
// * Verify conversion option is enabled
verifyConversionOptionEnabled(true);
// # Convert to private
convertChannelToPrivate();
// # Close the modal
closeChannelSettingsModal();
// * Verify channel is now private
verifyChannelIsPrivate(channel.display_name);
});
});
it('MM-T3348-4 - Team admin cannot convert when permission is removed', () => {
// # Setup permissions - remove permission from team admin
setupPermissions({
resetToDefault: true,
removeFromTeamAdmin: true,
});
// # Make test user a team admin
makeUserTeamAdmin(testTeam.id, testUser.id);
// # Have admin create a public channel
cy.apiAdminLogin();
cy.apiCreateChannel(testTeam.id, 'admin-created-team-admin-no-perm', 'Admin Created Team Admin No Permission').then(({channel}) => {
// # Add the team admin user to the channel (as a regular member, not channel admin)
cy.apiAddUserToChannel(channel.id, testUser.id);
// # Login as the team admin user
cy.apiLogin(testUser);
// # Visit the channel
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
// # Open channel settings modal
openChannelSettingsModal();
// * Verify conversion option is disabled
verifyConversionOptionDisabled(true);
// # Close the modal
closeChannelSettingsModal();
});
});
});
describe('Channel Admin Tests', () => {
it('MM-T3349-1 - Channel admin can convert public to private when permission is enabled', () => {
// # Setup permissions - enable public to private conversion permission
setupPermissions({resetToDefault: true, publicToPrivate: true});
// # Create and visit a public channel
createAndVisitPublicChannel(testTeam.name, 'channel-admin-pub', 'Channel Admin Public').then((channel) => {
// # Make test user a channel admin
makeUserChannelAdmin(channel.id, testUser.id);
// # Visit the channel again after role change
visitChannel(testTeam.name, channel.name);
// # Open channel settings modal
openChannelSettingsModal();
// * Verify conversion option is enabled
verifyConversionOptionEnabled(true);
// # Convert to private
convertChannelToPrivate();
// # Close the modal
closeChannelSettingsModal();
// * Verify channel is now private
verifyChannelIsPrivate(channel.display_name);
});
});
it('MM-T3349-2 - Channel admin cannot convert public to private when permission is removed', () => {
// Generate a unique channel name with timestamp to avoid conflicts
const timestamp = Date.now();
const channelId = `channel-admin-no-perm-${timestamp}`;
const displayName = `Channel Admin No Permission ${timestamp}`;
// # Setup permissions - remove public to private conversion permission
setupPermissions({resetToDefault: true, publicToPrivate: false, removeFromTeamAdmin: true});
// # Create and visit a public channel
createAndVisitPublicChannel(testTeam.name, channelId, displayName).then((channel) => {
// # Make test user a channel admin
makeUserChannelAdmin(channel.id, testUser.id);
// # Log back in as the test user after making channel admin
cy.apiLogin(testUser);
// # Wait for login to complete
cy.wait(500);
// # Visit the channel again after role change
visitChannel(testTeam.name, channel.name);
// # Wait for channel to load
cy.wait(500);
// # Open channel settings modal
openChannelSettingsModal();
// * Verify conversion option is disabled
verifyConversionOptionDisabled(true);
// # Close the modal
closeChannelSettingsModal();
});
});
});
});

View file

@ -58,19 +58,7 @@ describe('Leave and Archive channel actions display as destructive', () => {
cy.get('#channelMembers').should('be.visible');
// * Channel Settings menu option should be visible
cy.findByText('Channel Settings').should('be.visible').trigger('mouseover');
// * Edit Channel Header menu option should be visible
cy.get('#channelEditHeader').should('be.visible');
// * Edit Channel Purpose menu option should be visible
cy.get('#channelEditPurpose').should('be.visible');
// * Rename Channel menu option should be visible
cy.get('#channelRename').should('be.visible');
// * Channel Settings close menu option
cy.findByText('Channel Settings').should('be.visible').trigger('mouseout');
cy.findByText('Channel Settings').should('be.visible');
// * Archive Channel menu option should be visible and have a background-color (destructive)
cy.get('#channelArchiveChannel').should('be.visible').focus().should('have.css', 'background-color', 'rgb(210, 75, 78)');

View file

@ -14,15 +14,13 @@ import * as TIMEOUTS from '../../../fixtures/timeouts';
import {getRandomId} from '../../../utils';
describe('Channel routing', () => {
let testTeam: Cypress.Team;
let testUser: Cypress.UserProfile;
let testChannel: Cypress.Channel;
let testTeam: any;
let testUser: any;
before(() => {
cy.apiInitSetup().then(({team, user, channel}) => {
cy.apiInitSetup().then(({team, user}) => {
testTeam = team;
testUser = user;
testChannel = channel;
// # Login as test user and go to town square
cy.apiLogin(testUser);
@ -34,24 +32,25 @@ describe('Channel routing', () => {
// # Create new test channel
cy.uiCreateChannel({name: 'Test__Channel'});
// # Click on channel menu and press rename channel
cy.get('#channelHeaderTitle').click();
// # Open channel settings modal from channel header dropdown
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// * Channel Settings menu option should be visible
cy.findByText('Channel Settings').should('be.visible').trigger('mouseover');
cy.findByText('Rename Channel').click();
// # Assert if the channel settings modal is present
cy.get('.ChannelSettingsModal').should('be.visible');
// # Assert if the rename modal present
cy.get('[aria-labelledby="renameChannelModalLabel"').should('be.visible').within(() => {
// # type the two 26 character strings with 2 underscores between them and click on save
cy.get('#channel_name').clear().type('uzsfmtmniifsjgesce4u7yznyh__uzsfmtmniifsjgesce5u7yznyh', {force: true}).wait(TIMEOUTS.HALF_SEC);
cy.get('#save-button').should('be.visible').click();
// # Click on the URL input button to edit the URL
cy.get('.url-input-button').should('be.visible').click({force: true});
// # Assert the error occurred with the appropriate message
cy.get('.input__help').should('have.class', 'error');
cy.get('.input__help').should('have.text', 'User IDs are not allowed in channel URLs.');
cy.findByText('Cancel').click();
});
// # Type the two 26 character strings with 2 underscores between them
cy.get('.url-input-container input').clear().type('uzsfmtmniifsjgesce4u7yznyh__uzsfmtmniifsjgesce5u7yznyh', {force: true}).wait(TIMEOUTS.HALF_SEC);
// # Assert the error occurred with the appropriate message
cy.get('.SaveChangesPanel').should('contain', 'There are errors in the form above');
cy.get('.url-input-error').should('contain', 'User IDs are not allowed in channel URLs.');
// # Close the modal
cy.get('.GenericModal .modal-header button[aria-label="Close"]').click();
});
it('MM-T884_2 Creating new channel validates against two user IDs being used as channel name', () => {
@ -97,42 +96,34 @@ describe('Channel routing', () => {
it('MM-T883 Channel URL validation for spaces between characters', () => {
const firstWord = getRandomId(26);
const secondWord = getRandomId(26);
const channelName = 'test-channel-' + getRandomId(8);
// # In a test channel, click the "v" to the right of the channel name in the header
cy.findByText(`${testChannel.display_name}`).click();
cy.get('#channelHeaderTitle').click();
// # Create a new test channel and navigate to it
cy.apiCreateChannel(testTeam.id, channelName, 'Test Channel for URL Validation').then(({channel}) => {
cy.apiAddUserToChannel(channel.id, testUser.id);
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
});
// * Channel Settings menu option should be visible
cy.findByText('Channel Settings').should('be.visible').trigger('mouseover');
// # Select "Rename Channel"
cy.findByText('Rename Channel').click();
// # Open channel settings modal from channel header dropdown
cy.get('#channelHeaderDropdownButton').click();
cy.findByText('Channel Settings').click();
// # Change the channel name to {26 alphanumeric characters}[insert 2 spaces]{26 alphanumeric characters}
// i.e. a total of 54 characters separated by 2 spaces
cy.get('#display_name').clear().type(`${firstWord}${Cypress._.repeat(' ', 2)}${secondWord}`);
cy.get('#input_channel-settings-name').clear().type(`${firstWord}${Cypress._.repeat(' ', 2)}${secondWord}`);
// # Hit Save
cy.findByText('Save').click();
// # Save changes
cy.get('[data-testid="SaveChangesPanel__save-btn"]').click();
// * Verify changes are saved
cy.get('.SaveChangesPanel').should('contain', 'Settings saved');
// # Close the modal
cy.get('.GenericModal .modal-header button[aria-label="Close"]').click();
// * The channel name should be updated to the characters you typed with only 1 space between the characters (extra spaces are trimmed)
cy.get('#channelHeaderTitle').contains(`${firstWord} ${secondWord}`);
// # In a test channel, click the "v" to the right of the channel name in the header
cy.get('#channelHeaderTitle').click();
// * Channel Settings menu option should be visible
cy.findByText('Channel Settings').should('be.visible').trigger('mouseover');
// # Select "Rename Channel"
cy.findByText('Rename Channel').click();
// # Change the URL to {26 alphanumeric characters}--{26 alphanumeric characters}
cy.get('#channel_name').clear().type(`${firstWord}${Cypress._.repeat('-', 2)}${secondWord}`);
// # Hit Save
cy.findByText('Save').click();
// * The channel URL should be updated to the characters you typed, separated by 2 dashes
cy.url().should('equal', `${Cypress.config('baseUrl')}/${testTeam.name}/channels/${firstWord}${Cypress._.repeat('-', 2)}${secondWord}`);
});

View file

@ -202,22 +202,33 @@ describe('Team Permissions', () => {
// # Visit the `Off-Topic` channel in the new team
cy.visit(`/${team.name}/channels/off-topic`);
// # Open channel header menu
cy.uiOpenChannelMenu().wait(TIMEOUTS.HALF_SEC);
// # Open channel header dropdown
cy.get('#channelHeaderDropdownButton').click();
// * Verify dropdown opens
cy.get('#channelHeaderDropdownMenu').should('be.visible');
cy.findByText('Channel Settings').should('be.visible').trigger('mouseover');
// * Verify Channel Settings option is visible
cy.findByText('Channel Settings').should('be.visible');
// * Verify `Edit Channel Header` menu item is visible
cy.get('#channelEditHeader').should('be.visible');
// # Click on Channel Settings
cy.findByText('Channel Settings').click();
// * Verify `Edit Channel Purpose` menu item is visible
cy.get('#channelEditPurpose').should('be.visible');
// * Verify Channel Settings modal opens
cy.get('.ChannelSettingsModal').should('be.visible');
// * Verify `Rename Channel` menu item is visible
cy.get('#channelRename').should('be.visible');
// * Verify user can edit channel name
cy.get('#input_channel-settings-name').should('be.visible').and('not.be.disabled');
// * Verify user can edit channel URL
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');
// * Verify user can edit channel header
cy.get('#channel_settings_header_textbox').should('be.visible').and('not.be.disabled');
// # Close the modal
cy.get('.GenericModal .modal-header button[aria-label="Close"]').click();
cy.get('.ChannelSettingsModal').should('not.exist');
});
});
});

View file

@ -114,13 +114,15 @@ describe('Keyboard shortcut CTRL/CMD+Shift+\\ for adding reaction to last messag
cy.uiOpenProfileModal('Profile Settings');
verifyEmojiPickerNotOpen();
['Edit Channel Header', 'Rename Channel'].forEach((modal) => {
// # Click on the channel name in the channel header to open the channel menu options
cy.get('#channelHeaderTitle').click();
// # Open Channel Settings Modal
cy.uiOpenChannelMenu('Channel Settings');
verifyEmojiPickerNotOpen();
// # Select the "Edit Channel Header" option from the dropdown
cy.findByText('Channel Settings').should('be.visible').trigger('mouseover');
cy.findByText(modal).click();
['Channel Purpose', 'Channel Header'].forEach((modal) => {
// # Open channel menu and click View Info
cy.uiOpenChannelMenu('View Info');
cy.findByText(modal).parent().findAllByRole('button', {name: 'Edit'}).click();
doReactToLastMessageShortcut();
@ -129,6 +131,9 @@ describe('Keyboard shortcut CTRL/CMD+Shift+\\ for adding reaction to last messag
// # Close the modal
pressEscapeKey();
// # Close channel info
cy.uiOpenChannelMenu('Close Info');
});
});

View file

@ -53,7 +53,7 @@ describe('Onboarding', () => {
// # Enable any user with an account on the server to join the team
cy.get('input.mm-modal-generic-section-item__input-checkbox').last().should('be.visible').click();
cy.findAllByTestId('mm-save-changes-panel__save-btn').should('be.visible').click();
cy.get('[data-testid="SaveChangesPanel__save-btn"]').should('be.visible').click();
// # Close the modal
cy.findByLabelText('Close').should('be.visible').click();

View file

@ -102,11 +102,19 @@ describe('channels > run', {testIsolation: true}, () => {
cy.startPlaybookRunWithSlashCommand('Playbook', playbookRunName);
cy.verifyPlaybookRunActive(testTeam.id, playbookRunName);
// # Open the channel header
cy.get('#channelHeaderTitle').click();
cy.findByText('Channel Settings').should('be.visible').trigger('mouseover');
// # Open channel header dropdown
cy.get('#channelHeaderDropdownButton').click();
// # Click on Channel Settings
cy.findByText('Channel Settings').should('be.visible').click();
// * Verify Channel Settings modal opens
cy.get('.ChannelSettingsModal').should('be.visible');
// * Verify the ability to edit the channel header exists
cy.get('#channelEditHeader').should('exist');
cy.get('#channel_settings_header_textbox').should('be.visible').and('not.be.disabled');
// # Close the modal
cy.get('.GenericModal .modal-header button[aria-label="Close"]').click();
});
});

View file

@ -462,20 +462,35 @@ function getCurrentChannelId(): ChainableT<string> {
Cypress.Commands.add('getCurrentChannelId', getCurrentChannelId);
function updateChannelHeader(text: string) {
cy.get('#channelHeaderTitle').
should('be.visible').
click();
cy.get('#channelHeaderDropdownMenu').
should('be.visible');
// # Open channel header dropdown
cy.get('#channelHeaderDropdownButton').click();
// * Channel Settings menu option should be visible
cy.findByText('Channel Settings').should('be.visible').trigger('mouseover');
cy.findByText('Edit Channel Header').click();
cy.get('#edit_textbox').
// # Click on Channel Settings
cy.findByText('Channel Settings').should('be.visible').click();
// * Verify Channel Settings modal opens
cy.get('.ChannelSettingsModal').should('be.visible');
// # Edit channel header in the modal
cy.get('#channel_settings_header_textbox').
should('be.visible').
clear().
type(text).
type('{enter}').
wait(TIMEOUTS.HALF_SEC);
type(text);
// # Save changes
cy.get('[data-testid="SaveChangesPanel__save-btn"]').click();
// * Verify changes are saved
cy.get('.SaveChangesPanel').should('contain', 'Settings saved');
// # Close the modal
cy.get('.GenericModal .modal-header button[aria-label="Close"]').click();
// * Verify modal is closed
cy.get('.ChannelSettingsModal').should('not.exist');
// Wait for UI to stabilize
cy.wait(TIMEOUTS.HALF_SEC);
}
Cypress.Commands.add('updateChannelHeader', updateChannelHeader);

View file

@ -1,18 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
import {Locator, expect, Page} from '@playwright/test';
import ChannelsHeader from './header';
import ChannelsPostCreate from './post_create';
import ChannelsPostEdit from './post_edit';
import ChannelsPost from './post';
import {duration} from '@/util';
import {duration, hexToRgb} from '@/util';
import {waitUntil} from '@/test_action';
export default class ChannelsCenterView {
readonly container: Locator;
readonly page: Page;
readonly header;
readonly postCreate;
@ -27,9 +28,12 @@ export default class ChannelsCenterView {
readonly scheduledDraftSeeAllLink;
readonly postEdit;
readonly editedPostIcon;
readonly channelBanner;
constructor(container: Locator) {
constructor(container: Locator, page: Page) {
this.container = container;
this.page = page;
this.scheduledDraftChannelInfoMessageLocator = 'span:has-text("Message scheduled for")';
this.scheduledDraftDMChannelLocatorString = 'div.ScheduledPostIndicator span a';
this.header = new ChannelsHeader(this.container.locator('.channel-header'));
@ -43,6 +47,7 @@ export default class ChannelsCenterView {
this.scheduledDraftDMChannelLocator = container.locator(this.scheduledDraftDMChannelLocatorString);
this.scheduledDraftSeeAllLink = container.locator('a:has-text("See all")');
this.editedPostIcon = (postID: string) => container.locator(`#postEdited_${postID}`);
this.channelBanner = container.getByTestId('channel_banner_container');
}
async toBeVisible() {
@ -156,4 +161,45 @@ export default class ChannelsCenterView {
await this.editedPostIcon(postID).click();
}
}
async assertChannelBanner(text: string, backgroundColor: string) {
await expect(this.channelBanner).toBeVisible();
const actualText = await this.channelBanner.textContent();
expect(actualText).toBe(text);
const actualBackgroundColor = await this.channelBanner.evaluate((el) => {
return window.getComputedStyle(el).getPropertyValue('background-color');
});
expect(actualBackgroundColor).toBe(hexToRgb(backgroundColor));
}
async assertChannelBannerNotVisible() {
await expect(this.channelBanner).not.toBeVisible();
}
async assertChannelBannerHasBoldText(text: string) {
const boldText = await this.channelBanner.locator('strong');
expect(boldText).toBeVisible();
const actualText = await boldText.textContent();
expect(actualText).toBe(text);
}
async assertChannelBannerHasItalicText(text: string) {
const italicText = await this.channelBanner.locator('em');
expect(italicText).toBeVisible();
const actualText = await italicText.textContent();
expect(actualText).toBe(text);
}
async assertChannelBannerHasStrikethroughText(text: string) {
const strikethroughText = await this.channelBanner.locator('del');
expect(strikethroughText).toBeVisible();
const actualText = await strikethroughText.textContent();
expect(actualText).toBe(text);
}
}

View file

@ -6,11 +6,20 @@ import {Locator, expect} from '@playwright/test';
export default class ChannelsHeader {
readonly container: Locator;
readonly channelMenuDropdown;
constructor(container: Locator) {
this.container = container;
this.channelMenuDropdown = container.locator('[aria-controls="channelHeaderDropdownMenu"]');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async openChannelMenu() {
await this.channelMenuDropdown.isVisible();
await this.channelMenuDropdown.click();
}
}

View file

@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class ConfigurationSettings {
readonly container: Locator;
constructor(container: Locator) {
this.container = container;
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async save() {
const saveButton = this.container.getByTestId('SaveChangesPanel__save-btn');
await expect(saveButton).toBeVisible();
await saveButton.click();
}
async enableChannelBanner() {
const toggleButton = await this.container.getByTestId('channelBannerToggle-button');
const classes = await toggleButton.getAttribute('class');
if (!classes?.includes('active')) {
await toggleButton.click();
}
}
async disableChannelBanner() {
const toggleButton = await this.container.getByTestId('channelBannerToggle-button');
const classes = await toggleButton.getAttribute('class');
if (classes?.includes('active')) {
await toggleButton.click();
}
}
async setChannelBannerText(text: string) {
const textBox = await this.container.getByTestId('channel_banner_banner_text_textbox');
await expect(textBox).toBeVisible();
await textBox.fill(text);
}
async setChannelBannerTextColor(color: string) {
const colorInput = await this.container.locator(
'#channel_banner_banner_background_color_picker-inputColorValue',
);
await expect(colorInput).toBeVisible();
await colorInput.fill(color);
}
}

View file

@ -6,11 +6,16 @@ import {Locator, expect} from '@playwright/test';
import DisplaySettings from './display_settings';
import NotificationsSettings from './notification_settings';
import ConfigurationSettings from '@/ui/components/channels/settings/configuration_settings';
export default class SettingsModal {
readonly container: Locator;
readonly notificationsSettingsTab;
readonly configurationSettingsTab;
readonly notificationsSettings;
readonly configurationSettings;
readonly displaySettingsTab;
readonly displaySettings;
@ -19,7 +24,12 @@ export default class SettingsModal {
this.container = container;
this.notificationsSettingsTab = container.locator('#notificationsButton');
this.configurationSettingsTab = container.locator('#configurationButton');
this.notificationsSettings = new NotificationsSettings(container.locator('#notificationsSettings'));
this.configurationSettings = new ConfigurationSettings(
container.locator('.ChannelSettingsModal__configurationTab'),
);
this.displaySettingsTab = container.locator('#displayButton');
this.displaySettings = new DisplaySettings(container.locator('#displaySettings'));
@ -52,4 +62,19 @@ export default class SettingsModal {
await expect(this.container).not.toBeVisible();
}
async openConfigurationTab(): Promise<ConfigurationSettings> {
await expect(this.configurationSettingsTab).toBeVisible();
await this.configurationSettingsTab.click();
await this.configurationSettings.toBeVisible();
return this.configurationSettings;
}
async close() {
const closeButton = this.container.locator('button.close');
await expect(closeButton).toBeVisible();
await closeButton.click();
}
}

View file

@ -4,6 +4,7 @@
import {expect, Page} from '@playwright/test';
import {ChannelsPost, components} from '@/ui/components';
import SettingsModal from '@/ui/components/channels/settings/settings_modal';
export default class ChannelsPage {
readonly channels = 'Channels';
@ -38,7 +39,7 @@ export default class ChannelsPage {
// The main areas of the app
this.globalHeader = new components.GlobalHeader(this, page.locator('#global-header'));
this.searchPopover = new components.SearchPopover(page.locator('#searchPopover'));
this.centerView = new components.ChannelsCenterView(page.getByTestId('channel_view'));
this.centerView = new components.ChannelsCenterView(page.getByTestId('channel_view'), page);
this.sidebarLeft = new components.ChannelsSidebarLeft(page.locator('#SidebarContainer'));
this.sidebarRight = new components.ChannelsSidebarRight(page.locator('#sidebar-right'));
this.appBar = new components.ChannelsAppBar(page.locator('.app-bar'));
@ -64,6 +65,8 @@ export default class ChannelsPage {
// Posts
this.postContainer = page.locator('div.post-message__text');
page.locator('#channelHeaderDropdownMenu');
}
async toBeVisible() {
@ -95,6 +98,28 @@ export default class ChannelsPage {
await this.centerView.postMessage(message, files);
}
async openChannelSettings(): Promise<SettingsModal> {
await this.centerView.header.openChannelMenu();
await this.page.locator('#channelSettings[role="menuitem"]').click();
await this.settingsModal.toBeVisible();
return this.settingsModal;
}
async newChannel(name: string, channelType: string) {
await this.page.locator('#browseOrAddChannelMenuButton').click();
await this.page.locator('#createNewChannelMenuItem').click();
await this.page.locator('#input_new-channel-modal-name').fill(name);
if (channelType === 'P') {
await this.page.locator('#public-private-selector-button-P').click();
} else {
await this.page.locator('#public-private-selector-button-O').click();
}
await this.page.getByText('Create channel').click();
}
async openUserAccountMenu() {
await this.userAccountMenuButton.click();
await expect(this.userAccountMenu.container).toBeVisible();

View file

@ -46,3 +46,16 @@ export const defaultTeam = {name: 'ad-1', displayName: 'eligendi', type: 'O'};
export const illegalRe = /[/?<>\\:*|":&();]/g;
export const simpleEmailRe = /\S+@\S+\.\S+/;
export function hexToRgb(hex: string): string {
// Remove the # if present
hex = hex.replace(/^#/, '');
// Parse the hex values
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
// Return the RGB string
return `rgb(${r}, ${g}, ${b})`;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {getRandomId} from 'utils/utils';
test('Should show channel banner when configured', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'premium', 'Skipping test - server does not have Premium license');
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await channelsPage.newChannel(getRandomId(), 'O');
let settingsModal = await channelsPage.openChannelSettings();
let configurationTab = await settingsModal.openConfigurationTab();
await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerText('Example channel banner text');
await configurationTab.setChannelBannerTextColor('#77DD88');
await configurationTab.save();
await settingsModal.closeModal();
await channelsPage.centerView.assertChannelBanner('Example channel banner text', '#77DD88');
// Now we'll disable the channel banner
settingsModal = await channelsPage.openChannelSettings();
configurationTab = await settingsModal.openConfigurationTab();
await configurationTab.disableChannelBanner();
await configurationTab.save();
await settingsModal.closeModal();
await channelsPage.centerView.assertChannelBannerNotVisible();
// re-enabling channel banner should already have
// the previously configured text and color
settingsModal = await channelsPage.openChannelSettings();
configurationTab = await settingsModal.openConfigurationTab();
await configurationTab.enableChannelBanner();
await configurationTab.save();
await settingsModal.closeModal();
await channelsPage.centerView.assertChannelBanner('Example channel banner text', '#77DD88');
});
test('Should render markdown', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'premium', 'Skipping test - server does not have Premium license');
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await channelsPage.newChannel(getRandomId(), 'O');
const settingsModal = await channelsPage.openChannelSettings();
const configurationTab = await settingsModal.openConfigurationTab();
await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerText('**bold** *italic* ~~strikethrough~~');
await configurationTab.setChannelBannerTextColor('#77DD88');
await configurationTab.save();
await settingsModal.closeModal();
await channelsPage.centerView.assertChannelBannerHasBoldText('bold');
await channelsPage.centerView.assertChannelBannerHasItalicText('italic');
await channelsPage.centerView.assertChannelBannerHasStrikethroughText('strikethrough');
});

View file

@ -0,0 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {v4 as uuidv4} from 'uuid';
export function getRandomId(length = 7): string {
const MAX_SUBSTRING_INDEX = 27;
return uuidv4()
.replace(/-/g, '')
.substring(MAX_SUBSTRING_INDEX - length, MAX_SUBSTRING_INDEX);
}

View file

@ -2461,7 +2461,7 @@ func convertGroupMessageToChannel(c *Context, w http.ResponseWriter, r *http.Req
func canEditChannelBanner(license *model.License, originalChannel *model.Channel) *model.AppError {
if !model.MinimumPremiumLicense(license) {
return model.NewAppError("", "license_error.feature_unavailable", nil, "feature is not available for the current license", http.StatusForbidden)
return model.NewAppError("", "license_error.feature_unavailable.specific", map[string]any{"Feature": "Channel Banner"}, "feature is not available for the current license", http.StatusForbidden)
}
if originalChannel.Type != model.ChannelTypeOpen && originalChannel.Type != model.ChannelTypePrivate {

View file

@ -5747,7 +5747,7 @@ func TestCanEditChannelBanner(t *testing.T) {
err := canEditChannelBanner(nil, channel)
require.NotNil(t, err)
assert.Equal(t, "license_error.feature_unavailable", err.Id)
assert.Equal(t, "license_error.feature_unavailable.specific", err.Id)
assert.Equal(t, http.StatusForbidden, err.StatusCode)
})
@ -5760,7 +5760,7 @@ func TestCanEditChannelBanner(t *testing.T) {
err := canEditChannelBanner(license, channel)
require.NotNil(t, err)
assert.Equal(t, "license_error.feature_unavailable", err.Id)
assert.Equal(t, "license_error.feature_unavailable.specific", err.Id)
assert.Equal(t, http.StatusForbidden, err.StatusCode)
})

View file

@ -8472,6 +8472,10 @@
"id": "license_error.feature_unavailable",
"translation": "Feature is not available for the current license"
},
{
"id": "license_error.feature_unavailable.specific",
"translation": "{{.Feature}} feature is not available for the current license"
},
{
"id": "manaultesting.manual_test.parse.app_error",
"translation": "Unable to parse URL."

View file

@ -139,6 +139,7 @@ type ChannelPatch struct {
Header *string `json:"header"`
Purpose *string `json:"purpose"`
GroupConstrained *bool `json:"group_constrained"`
Type ChannelType `json:"type"`
BannerInfo *ChannelBannerInfo `json:"banner_info"`
}

View file

@ -32,6 +32,7 @@ import {
isManuallyUnread,
getCurrentChannelId,
} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getMostRecentPostIdInChannel, getPost} from 'mattermost-redux/selectors/entities/posts';
import {
getCurrentRelativeTeamUrl,
@ -49,7 +50,7 @@ import {openDirectChannelToUserId} from 'actions/channel_actions';
import {loadCustomStatusEmojisForPostList} from 'actions/emoji_actions';
import {closeRightHandSide} from 'actions/views/rhs';
import {markThreadAsRead} from 'actions/views/threads';
import {getLastViewedChannelName} from 'selectors/local_storage';
import {getLastViewedChannelName, getPenultimateViewedChannelName} from 'selectors/local_storage';
import {getSelectedPost, getSelectedPostId} from 'selectors/rhs';
import {getLastPostsApiTimeForChannel} from 'selectors/views/channel';
import {getSelectedThreadIdInCurrentTeam} from 'selectors/views/threads';
@ -59,6 +60,7 @@ import LocalStorageStore from 'stores/local_storage_store';
import {getHistory} from 'utils/browser_history';
import {isArchivedChannel} from 'utils/channel_utils';
import {Constants, ActionTypes, EventTypes, PostRequestTypes} from 'utils/constants';
import {stopTryNotificationRing} from 'utils/notification_sounds';
import type {ActionFuncAsync, ThunkActionFunc} from 'types/store';
@ -524,18 +526,42 @@ export function updateToastStatus(status: boolean) {
export function deleteChannel(channelId: string): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
// Get state before deletion
const state = getState();
const channel = getChannel(state, channelId);
// Validate channel ID
if (!channel || channel.id.length !== Constants.CHANNEL_ID_LENGTH) {
return {data: false};
}
const canViewArchivedChannels = getConfig(state).ExperimentalViewArchivedChannels === 'true';
const currentTeamDetails = getCurrentTeam(state);
const penultimateViewedChannelName = getPenultimateViewedChannelName(state) ||
getRedirectChannelNameForTeam(state, getCurrentTeamId(state));
// Handle redirection before deletion if needed
if (!canViewArchivedChannels && penultimateViewedChannelName && currentTeamDetails) {
getHistory().push('/' + currentTeamDetails.name + '/channels/' + penultimateViewedChannelName);
}
// Call the delete channel action
const res = await dispatch(deleteChannelRedux(channelId));
if (res.error) {
return {data: false};
}
const state = getState();
const selectedPost = getSelectedPost(state);
const selectedPostId = getSelectedPostId(state);
// Handle RHS state
const updatedState = getState();
const selectedPost = getSelectedPost(updatedState);
const selectedPostId = getSelectedPostId(updatedState);
if (selectedPostId && !selectedPost.exists) {
dispatch(closeRightHandSide());
}
// Stop notification sounds
stopTryNotificationRing();
return {data: true};
};
}

View file

@ -23,3 +23,17 @@ export function setShowPreviewOnEditChannelHeaderModal(showPreview) {
showPreview,
};
}
export function setShowPreviewOnChannelSettingsHeaderModal(showPreview) {
return {
type: ActionTypes.SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_HEADER_MODAL,
showPreview,
};
}
export function setShowPreviewOnChannelSettingsPurposeModal(showPreview) {
return {
type: ActionTypes.SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_PURPOSE_MODAL,
showPreview,
};
}

View file

@ -13,7 +13,7 @@ import type {FileUpload} from 'components/file_upload/file_upload';
import type Textbox from 'components/textbox/textbox';
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
import {renderWithContext, userEvent, screen} from 'tests/react_testing_utils';
import {renderWithContext, userEvent, screen, act} from 'tests/react_testing_utils';
import Constants, {Locations, StoragePrefixes} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
@ -184,7 +184,7 @@ const baseProps = {
describe('components/avanced_text_editor/advanced_text_editor', () => {
describe('keyDown behavior', () => {
it('ESC should blur the input', () => {
it('ESC should blur the input', async () => {
renderWithContext(
<AdvancedTextEditor
{...baseProps}
@ -200,12 +200,16 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
}),
);
const textbox = screen.getByTestId('post_textbox');
userEvent.type(textbox, 'something{esc}');
await act(async () => {
userEvent.type(textbox, 'something{esc}');
});
expect(textbox).not.toHaveFocus();
expect(mockedUpdateDraft).not.toHaveBeenCalled();
});
it('ESC should blur the input and reset draft when in editing mode', () => {
it('ESC should blur the input and reset draft when in editing mode', async () => {
jest.useFakeTimers();
const props = {
...baseProps,
@ -226,7 +230,9 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
}),
);
const textbox = screen.getByTestId('edit_textbox');
userEvent.type(textbox, 'something{esc}');
await act(async () => {
userEvent.type(textbox, 'something{esc}');
});
expect(textbox).not.toHaveFocus();
// save is called with a short delayed after pressing escape key
@ -236,7 +242,7 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
});
});
it('should set the textbox value to an existing draft on mount and when changing channels', () => {
it('should set the textbox value to an existing draft on mount and when changing channels', async () => {
const {rerender} = renderWithContext(
<AdvancedTextEditor
{...baseProps}
@ -261,16 +267,18 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
expect(screen.getByPlaceholderText('Write to Test Channel')).toHaveValue('original draft');
rerender(
<AdvancedTextEditor
{...baseProps}
channelId={otherChannelId}
/>,
);
await act(async () => {
rerender(
<AdvancedTextEditor
{...baseProps}
channelId={otherChannelId}
/>,
);
});
expect(screen.getByPlaceholderText('Write to Other Channel')).toHaveValue('a different draft');
});
it('should save a new draft when changing channels', () => {
it('should save a new draft when changing channels', async () => {
const {rerender} = renderWithContext(
<AdvancedTextEditor
{...baseProps}
@ -278,16 +286,20 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
initialState,
);
userEvent.type(screen.getByPlaceholderText('Write to Test Channel'), 'some text');
await act(async () => {
userEvent.type(screen.getByPlaceholderText('Write to Test Channel'), 'some text');
});
expect(mockedUpdateDraft).not.toHaveBeenCalled();
rerender(
<AdvancedTextEditor
{...baseProps}
channelId={otherChannelId}
/>,
);
await act(async () => {
rerender(
<AdvancedTextEditor
{...baseProps}
channelId={otherChannelId}
/>,
);
});
expect(mockedUpdateDraft).toHaveBeenCalled();
expect(mockedUpdateDraft.mock.calls[0][1]).toMatchObject({
@ -296,7 +308,7 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
});
});
it('MM-60541 should not save an unmodified draft when changing channels', () => {
it('MM-60541 should not save an unmodified draft when changing channels', async () => {
const {rerender} = renderWithContext(
<AdvancedTextEditor
{...baseProps}
@ -316,17 +328,19 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
expect(mockedUpdateDraft).not.toHaveBeenCalled();
rerender(
<AdvancedTextEditor
{...baseProps}
channelId={otherChannelId}
/>,
);
await act(async () => {
rerender(
<AdvancedTextEditor
{...baseProps}
channelId={otherChannelId}
/>,
);
});
expect(mockedUpdateDraft).not.toHaveBeenCalled();
});
it('should save an updated draft when changing channels', () => {
it('should save an updated draft when changing channels', async () => {
const {rerender} = renderWithContext(
<AdvancedTextEditor
{...baseProps}
@ -344,16 +358,20 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
}),
);
userEvent.type(screen.getByPlaceholderText('Write to Test Channel'), ' plus some new text');
await act(async () => {
userEvent.type(screen.getByPlaceholderText('Write to Test Channel'), ' plus some new text');
});
expect(mockedUpdateDraft).not.toHaveBeenCalled();
rerender(
<AdvancedTextEditor
{...baseProps}
channelId={otherChannelId}
/>,
);
await act(async () => {
rerender(
<AdvancedTextEditor
{...baseProps}
channelId={otherChannelId}
/>,
);
});
expect(mockedUpdateDraft).toHaveBeenCalled();
expect(mockedUpdateDraft.mock.calls[0][1]).toMatchObject({
@ -362,7 +380,7 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
});
});
it('should deleted a deleted draft when changing channels', () => {
it('should deleted a deleted draft when changing channels', async () => {
const {rerender} = renderWithContext(
<AdvancedTextEditor
{...baseProps}
@ -380,23 +398,27 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
}),
);
userEvent.clear(screen.getByPlaceholderText('Write to Test Channel'));
await act(async () => {
userEvent.clear(screen.getByPlaceholderText('Write to Test Channel'));
});
expect(mockedRemoveDraft).not.toHaveBeenCalled();
expect(mockedUpdateDraft).not.toHaveBeenCalled();
rerender(
<AdvancedTextEditor
{...baseProps}
channelId={otherChannelId}
/>,
);
await act(async () => {
rerender(
<AdvancedTextEditor
{...baseProps}
channelId={otherChannelId}
/>,
);
});
expect(mockedRemoveDraft).toHaveBeenCalled();
expect(mockedUpdateDraft).not.toHaveBeenCalled();
});
it('MM-60541 should not attempt to delete a non-existent draft when changing channels', () => {
it('MM-60541 should not attempt to delete a non-existent draft when changing channels', async () => {
const {rerender} = renderWithContext(
<AdvancedTextEditor
{...baseProps}
@ -407,12 +429,14 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
expect(mockedRemoveDraft).not.toHaveBeenCalled();
expect(mockedUpdateDraft).not.toHaveBeenCalled();
rerender(
<AdvancedTextEditor
{...baseProps}
channelId={otherChannelId}
/>,
);
await act(async () => {
rerender(
<AdvancedTextEditor
{...baseProps}
channelId={otherChannelId}
/>,
);
});
expect(mockedRemoveDraft).not.toHaveBeenCalled();
expect(mockedUpdateDraft).not.toHaveBeenCalled();

View file

@ -10,7 +10,7 @@ import {renderWithContext} from 'tests/react_testing_utils';
import {LicenseSkus, Constants} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import ChannelBanner from './index';
import ChannelBanner from './channel_banner';
describe('components/channel_banner', () => {
const channel1 = TestHelper.getChannelMock({

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import React, {useEffect, useMemo, useRef} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
@ -30,6 +30,19 @@ type Props = {
export default function ChannelBanner({channelId}: Props) {
const channelBannerInfo = useSelector((state: GlobalState) => getChannelBanner(state, channelId));
const showChannelBanner = useSelector((state: GlobalState) => selectShowChannelBanner(state, channelId));
const textContainerRef = useRef<HTMLSpanElement>(null);
const [tooltipNeeded, setTooltipNeeded] = React.useState<boolean>(false);
useEffect(() => {
if (!textContainerRef.current) {
return;
}
const isOverflowingHorizontally = textContainerRef.current.offsetWidth < textContainerRef.current.scrollWidth;
const isOverflowingVertically = textContainerRef.current.offsetHeight < textContainerRef.current.scrollHeight;
setTooltipNeeded(isOverflowingHorizontally || isOverflowingVertically);
}, [channelBannerInfo?.text]);
const intl = useIntl();
const channelBannerTextAriaLabel = intl.formatMessage({id: 'channel_banner.aria_label', defaultMessage: 'Channel banner text'});
@ -75,6 +88,7 @@ export default function ChannelBanner({channelId}: Props) {
className='channelBannerTooltip'
delayClose={true}
forcedPlacement='bottom'
disabled={!tooltipNeeded}
>
<div
className='channel_banner'
@ -86,6 +100,7 @@ export default function ChannelBanner({channelId}: Props) {
className='channel_banner_text'
aria-label={channelBannerTextAriaLabel}
style={channelBannerTextStyle}
ref={textContainerRef}
>
{content}
</span>

View file

@ -5,7 +5,6 @@
width: 100%;
min-height: variables.$announcement-bar-height;
max-height: variables.$announcement-bar-height;
align-items: center;
justify-content: center;
padding-block: 6px;
padding-inline: 24px;
@ -18,18 +17,38 @@
text-align: center;
text-overflow: ellipsis;
&:has(p:first-child) {
margin-top: 3px;
}
a {
color: var(--channel-banner-text-color);
text-decoration: underline;
}
.markdown__heading {
overflow: hidden;
margin: 2px;
font-size: 18px;
text-overflow: ellipsis;
}
:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
.markdown__table {
background: unset;
}
}
}
.channelBannerTooltip {
top: -2px !important;
overflow: auto;
min-width: 400px;
max-width: min(1000px, 70vw);
max-height: 80vh;
pointer-events: auto;
}

View file

@ -19,7 +19,7 @@ import {Constants} from 'utils/constants';
import MenuItemArchiveChannel from '../menu_items/archive_channel';
import MenuItemChannelBookmarks from '../menu_items/channel_bookmarks_submenu';
import MenuItemChannelSettings from '../menu_items/channel_settings_submenu';
import MenuItemChannelSettings from '../menu_items/channel_settings_menu';
import MenuItemCloseChannel from '../menu_items/close_channel';
import MenuItemGroupsMenuItems from '../menu_items/groups';
import MenuItemLeaveChannel from '../menu_items/leave_channel';
@ -44,7 +44,7 @@ interface Props extends Menu.FirstMenuItemProps {
pluginItems: ReactNode[];
}
const ChannelHeaderPublicMenu = ({channel, user, isMuted, isReadonly, isDefault, isMobile, isFavorite, isLicensedForLDAPGroups, pluginItems, ...rest}: Props) => {
const ChannelHeaderPublicMenu = ({channel, user, isMuted, isDefault, isMobile, isFavorite, isLicensedForLDAPGroups, pluginItems, ...rest}: Props) => {
const isGroupConstrained = channel?.group_constrained === true;
const isArchived = channel.delete_at !== 0;
const isPrivate = channel?.type === Constants.PRIVATE_CHANNEL;
@ -71,8 +71,6 @@ const ChannelHeaderPublicMenu = ({channel, user, isMuted, isReadonly, isDefault,
channel={channel}
/>
<MenuItemChannelSettings
isReadonly={isReadonly}
isDefault={isDefault}
channel={channel}
/>
<MenuItemChannelBookmarks

View file

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {
CogOutlineIcon,
} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {openModal} from 'actions/views/modals';
import ChannelSettingsModal from 'components/channel_settings_modal/channel_settings_modal';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
channel: Channel;
}
const ChannelSettingsMenu = ({channel}: Props): JSX.Element => {
const dispatch = useDispatch();
const handleOpenChannelSettings = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.CHANNEL_SETTINGS,
dialogType: ChannelSettingsModal,
dialogProps: {
channelId: channel.id,
focusOriginElement: 'channelHeaderDropdownButton',
isOpen: true,
},
}),
);
};
return (
<Menu.Item
id={'channelSettings'}
labels={
<FormattedMessage
id='channel_header.channel_settings'
defaultMessage='Channel Settings'
/>
}
onClick={handleOpenChannelSettings}
leadingElement={<CogOutlineIcon size={18}/>}
/>
);
};
export default memo(ChannelSettingsMenu);

View file

@ -1,167 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {
ChevronRightIcon,
CogOutlineIcon,
} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {Permissions} from 'mattermost-redux/constants';
import {openModal} from 'actions/views/modals';
import ConvertChannelModal from 'components/convert_channel_modal';
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
import EditChannelPurposeModal from 'components/edit_channel_purpose_modal';
import * as Menu from 'components/menu';
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
import RenameChannelModal from 'components/rename_channel_modal';
import {Constants, ModalIdentifiers} from 'utils/constants';
type Props = {
channel: Channel;
isReadonly: boolean;
isDefault: boolean;
}
const ChannelSettingsSubmenu = ({channel, isReadonly, isDefault}: Props): JSX.Element => {
const dispatch = useDispatch();
const {formatMessage} = useIntl();
const channelPropertiesPermission = channel.type === Constants.PRIVATE_CHANNEL ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES;
const handleRenameChannel = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.RENAME_CHANNEL,
dialogType: RenameChannelModal,
dialogProps: {channel},
}),
);
};
const handleEditHeader = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
dialogType: EditChannelHeaderModal,
dialogProps: {channel},
}),
);
};
const handleEditPurpose = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE,
dialogType: EditChannelPurposeModal,
dialogProps: {channel},
}),
);
};
const handleConvertToPrivate = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.CONVERT_CHANNEL,
dialogType: ConvertChannelModal,
dialogProps: {
channelId: channel.id,
channelDisplayName: channel.display_name,
},
}),
);
};
return (
<Menu.SubMenu
id={'channelSettings'}
labels={
<FormattedMessage
id='channelSettings'
defaultMessage='Channel Settings'
/>
}
leadingElement={<CogOutlineIcon size={18}/>}
trailingElements={<ChevronRightIcon size={16}/>}
menuId={'channelSettings-menu'}
menuAriaLabel={formatMessage({id: 'channelSettings', defaultMessage: 'Channel Settings'})}
>
{!isReadonly && (
<Menu.Item
id='channelRename'
onClick={handleRenameChannel}
labels={
<FormattedMessage
id='channel_header.rename'
defaultMessage='Rename Channel'
/>
}
/>
)}
{!isReadonly && (
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelPropertiesPermission]}
>
<Menu.Item
id='channelEditHeader'
onClick={handleEditHeader}
labels={
<FormattedMessage
id='channel_header.setHeader'
defaultMessage='Edit Channel Header'
/>
}
/>
</ChannelPermissionGate>
)}
{!isReadonly && (
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelPropertiesPermission]}
>
<Menu.Item
id='channelEditPurpose'
onClick={handleEditPurpose}
labels={
<FormattedMessage
id='channel_header.setPurpose'
defaultMessage='Edit Channel Purpose'
/>
}
/>
</ChannelPermissionGate>
)}
{!isDefault && channel.type === Constants.OPEN_CHANNEL && (
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE]}
>
<Menu.Item
id='channelConvertToPrivate'
onClick={handleConvertToPrivate}
labels={
<FormattedMessage
id='channel_header.convert'
defaultMessage='Convert to Private Channel'
/>
}
/>
</ChannelPermissionGate>
)}
</Menu.SubMenu>
);
};
export default memo(ChannelSettingsSubmenu);

View file

@ -1,53 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as modalActions from 'actions/views/modals';
import ConvertChannelModal from 'components/convert_channel_modal';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import ConvertPublictoPrivate from './convert_public_to_private';
describe('components/ChannelHeaderMenu/MenuItems/ConvertPublicToPrivate', () => {
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
// Mock useDispatch to return our custom dispatch function
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
const channel = TestHelper.getChannelMock();
test('renders the component correctly, handle click event', () => {
renderWithContext(
<WithTestMenuContext>
<ConvertPublictoPrivate channel={channel}/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Convert to Private Channel');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.CONVERT_CHANNEL,
dialogType: ConvertChannelModal,
dialogProps: {
channelId: channel.id,
channelDisplayName: channel.display_name,
},
});
});
});

View file

@ -1,50 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import {openModal} from 'actions/views/modals';
import ConvertChannelModal from 'components/convert_channel_modal';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
channel: Channel;
}
const ConvertPublictoPrivate = ({channel}: Props): JSX.Element => {
const dispatch = useDispatch();
const handleConvertToPrivate = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.CONVERT_CHANNEL,
dialogType: ConvertChannelModal,
dialogProps: {
channelId: channel.id,
channelDisplayName: channel.display_name,
},
}),
);
};
return (
<Menu.Item
id='channelConvertToPrivate'
onClick={handleConvertToPrivate}
labels={
<FormattedMessage
id='channel_header.convert'
defaultMessage='Convert to Private Channel'
/>
}
/>
);
};
export default React.memo(ConvertPublictoPrivate);

View file

@ -1,52 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch} from 'react-redux';
import * as modalActions from 'actions/views/modals';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import RenameChannelModal from 'components/rename_channel_modal';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import {ModalIdentifiers} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import EditChannelSettings from './edit_channel_settings';
describe('components/ChannelHeaderMenu/MenuItems/EditChannelSettings', () => {
const channel = TestHelper.getChannelMock();
beforeEach(() => {
jest.spyOn(modalActions, 'openModal');
jest.spyOn(require('react-redux'), 'useDispatch');
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders the component correctly, handle click event', () => {
renderWithContext(
<WithTestMenuContext>
<EditChannelSettings
channel={channel}
isReadonly={false}
isDefault={false}
/>
</WithTestMenuContext>, {},
);
const menuItem = screen.getByText('Rename Channel');
expect(menuItem).toBeInTheDocument();
fireEvent.click(menuItem); // Simulate click on the menu item
expect(useDispatch).toHaveBeenCalledTimes(1); // Ensure dispatch was called
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
expect(modalActions.openModal).toHaveBeenCalledWith({
modalId: ModalIdentifiers.RENAME_CHANNEL,
dialogType: RenameChannelModal,
dialogProps: {channel},
});
});
});

View file

@ -1,121 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import {Permissions} from 'mattermost-redux/constants';
import {openModal} from 'actions/views/modals';
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
import EditChannelPurposeModal from 'components/edit_channel_purpose_modal';
import * as Menu from 'components/menu';
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
import RenameChannelModal from 'components/rename_channel_modal';
import {Constants, ModalIdentifiers} from 'utils/constants';
import MenuItemConvertToPrivate from './convert_public_to_private';
type Props = {
channel: Channel;
isReadonly: boolean;
isDefault: boolean;
}
const EditChannelSettings = ({channel, isReadonly, isDefault}: Props): JSX.Element => {
const dispatch = useDispatch();
const channelPropertiesPermission = channel.type === Constants.PRIVATE_CHANNEL ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES;
const handleRenameChannel = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.RENAME_CHANNEL,
dialogType: RenameChannelModal,
dialogProps: {channel},
}),
);
};
const handleEditHeader = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
dialogType: EditChannelHeaderModal,
dialogProps: {channel},
}),
);
};
const handleEditPurpose = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE,
dialogType: EditChannelPurposeModal,
dialogProps: {channel},
}),
);
};
return (
<>
{!isReadonly && (
<>
<Menu.Item
id='channelRename'
onClick={handleRenameChannel}
labels={
<FormattedMessage
id='channel_header.rename'
defaultMessage='Rename Channel'
/>
}
/>
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[channelPropertiesPermission]}
>
<Menu.Item
id='channelEditHeader'
onClick={handleEditHeader}
labels={
<FormattedMessage
id='channel_header.setHeader'
defaultMessage='Edit Channel Header'
/>
}
/>
<Menu.Item
id='channelEditPurpose'
onClick={handleEditPurpose}
labels={
<FormattedMessage
id='channel_header.setPurpose'
defaultMessage='Edit Channel Purpose'
/>
}
/>
</ChannelPermissionGate>
</>
)}
{!isDefault && channel.type === Constants.OPEN_CHANNEL && (
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE]}
>
<MenuItemConvertToPrivate
channel={channel}
/>
</ChannelPermissionGate>
)}
</>
);
};
export default React.memo(EditChannelSettings);

View file

@ -24,10 +24,12 @@ export type Props = {
placeholder: string;
onDisplayNameChange: (name: string) => void;
onURLChange: (url: string) => void;
currentUrl?: string;
autoFocus?: boolean;
onErrorStateChange?: (isError: boolean) => void;
onErrorStateChange?: (isError: boolean, errorMessage?: string) => void;
team?: Team;
urlError?: string;
readOnly?: boolean;
}
import './channel_name_form_field.scss';
@ -54,14 +56,20 @@ const ChannelNameFormField = (props: Props): JSX.Element => {
const intl = useIntl();
const {formatMessage} = intl;
const displayNameModified = useRef<boolean>(false);
// Track if the field has been interacted with
const [hasInteracted, setHasInteracted] = useState(false);
const [displayNameError, setDisplayNameError] = useState<string>('');
const displayName = useRef<string>('');
const urlModified = useRef<boolean>(false);
const [url, setURL] = useState<string>('');
const [url, setURL] = useState<string>(props.currentUrl || '');
const [urlError, setURLError] = useState<string>('');
const [inputCustomMessage, setInputCustomMessage] = useState<CustomMessageInputType | null>(null);
// Initialize displayName.current with props.value when component mounts
useEffect(() => {
displayName.current = props.value;
}, [props.value]);
const currentTeamName = useSelector(getCurrentTeam)?.name;
const teamName = props.team ? props.team.name : currentTeamName;
@ -69,10 +77,27 @@ const ChannelNameFormField = (props: Props): JSX.Element => {
e.preventDefault();
const {target: {value: updatedDisplayName}} = e;
const displayNameErrors = validateDisplayName(intl, updatedDisplayName);
// Mark as interacted when user types
if (!hasInteracted && updatedDisplayName.trim() !== '') {
setHasInteracted(true);
}
// Only validate if the user has interacted with the field
if (hasInteracted) {
const displayNameErrors = validateDisplayName(intl, updatedDisplayName);
if (displayNameErrors.length) {
setDisplayNameError(displayNameErrors[displayNameErrors.length - 1]);
setInputCustomMessage({
type: 'error',
value: displayNameErrors[displayNameErrors.length - 1],
});
} else {
setDisplayNameError('');
setInputCustomMessage(null);
}
}
// set error if any, else clear it
setDisplayNameError(displayNameErrors.length ? displayNameErrors[displayNameErrors.length - 1] : '');
displayName.current = updatedDisplayName;
props.onDisplayNameChange(updatedDisplayName);
@ -83,19 +108,32 @@ const ChannelNameFormField = (props: Props): JSX.Element => {
setURLError('');
props.onURLChange(cleanURL);
}
}, [props.onDisplayNameChange, props.onURLChange]);
}, [props.onDisplayNameChange, props.onURLChange, hasInteracted, intl]);
const handleOnDisplayNameBlur = useCallback(() => {
// Always mark as interacted on blur
setHasInteracted(true);
// Validate on blur - always show errors on blur regardless of interaction state
const displayNameErrors = validateDisplayName(intl, displayName.current);
setDisplayNameError(displayNameErrors.length ? displayNameErrors[displayNameErrors.length - 1] : '');
if (displayNameErrors.length) {
setInputCustomMessage({
type: 'error',
value: displayNameErrors[displayNameErrors.length - 1],
});
} else {
setInputCustomMessage(null);
}
// Handle URL generation if needed
if (displayName.current && !url) {
const url = generateSlug();
setURL(url);
props.onURLChange(url);
}
if (!displayNameModified.current) {
displayNameModified.current = true;
setInputCustomMessage(null);
}
}, [props.onURLChange, displayName.current, url, displayNameModified]);
}, [props.onURLChange, displayName.current, url, intl]);
const handleOnURLChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.preventDefault();
@ -110,12 +148,41 @@ const ChannelNameFormField = (props: Props): JSX.Element => {
props.onURLChange(cleanURL);
}, [props.onURLChange]);
// Add a URL blur handler to validate the URL when the user moves away from the field
const handleOnURLBlur = useCallback(() => {
// Only validate if the URL has been modified
if (urlModified.current) {
const urlErrors = validateChannelUrl(url, intl);
let lastError = '';
if (urlErrors.length && typeof urlErrors[urlErrors.length - 1] === 'string') {
// Safe to assert as string because we're always providing intl to validateChannelUrl and have extra type safe check
lastError = urlErrors[urlErrors.length - 1] as string;
}
setURLError(lastError);
}
}, [url, intl]);
useEffect(() => {
// Only report URL errors if the URL has been explicitly modified and validated
// This prevents showing errors during typing
if (props.onErrorStateChange) {
props.onErrorStateChange(Boolean(displayNameError) || Boolean(urlError));
if (displayNameError) {
props.onErrorStateChange(true, displayNameError);
} else if (urlModified.current && urlError) {
props.onErrorStateChange(true, urlError);
} else {
props.onErrorStateChange(false, '');
}
}
}, [displayNameError, urlError]);
// Effect to set URL from props if it's modified (used in modals for reset button event which sets the value outside the onChange event)
useEffect(() => {
if (props.currentUrl) {
setURL(props.currentUrl);
}
}, [props.currentUrl]);
return (
<>
<Input
@ -129,10 +196,14 @@ const ChannelNameFormField = (props: Props): JSX.Element => {
label={formatMessage({id: 'channel_modal.name.label', defaultMessage: 'Channel name'})}
placeholder={props.placeholder}
limit={Constants.MAX_CHANNELNAME_LENGTH}
// Only pass minLength after the user has interacted with the field
minLength={hasInteracted ? Constants.MIN_CHANNELNAME_LENGTH : undefined}
value={props.value}
customMessage={inputCustomMessage}
onChange={handleOnDisplayNameChange}
onBlur={handleOnDisplayNameBlur}
disabled={props.readOnly}
/>
<URLInput
className='new-channel-modal__url'
@ -143,6 +214,7 @@ const ChannelNameFormField = (props: Props): JSX.Element => {
shortenLength={Constants.DEFAULT_CHANNELURL_SHORTEN_LENGTH}
error={urlError || props.urlError}
onChange={handleOnURLChange}
onBlur={handleOnURLBlur}
/>
</>
);

View file

@ -46,6 +46,9 @@ export type Props = PropsFromRedux & {
*/
currentUser: UserProfile;
/**
* Id of the element that triggered the modal opening
*/
focusOriginElement?: string;
};

View file

@ -0,0 +1,200 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import type {ClientConfig} from '@mattermost/types/config';
import type {Team} from '@mattermost/types/teams';
import * as teams from 'mattermost-redux/selectors/entities/teams';
import * as channelActions from 'actions/views/channel';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import ChannelSettingsArchiveTab from './channel_settings_archive_tab';
// Mock the redux actions and selectors
jest.mock('actions/views/channel', () => ({
deleteChannel: jest.fn().mockReturnValue({type: 'MOCK_DELETE_ACTION'}),
}));
jest.mock('utils/browser_history', () => ({
getHistory: jest.fn(),
}));
jest.mock('mattermost-redux/selectors/entities/general', () => ({
...jest.requireActual('mattermost-redux/selectors/entities/general') as typeof import('mattermost-redux/selectors/entities/general'),
getConfig: () => mockConfig,
}));
// Mock the roles selector which is a dependency for other selectors
jest.mock('mattermost-redux/selectors/entities/roles', () => ({
haveITeamPermission: jest.fn().mockReturnValue(true),
haveIChannelPermission: jest.fn().mockReturnValue(true),
getRoles: jest.fn().mockReturnValue({}),
}));
// Create a mock channel for testing
const mockChannel = TestHelper.getChannelMock({
id: 'using-26-letter-channel-id', // so channel id validation length works
team_id: 'team1',
display_name: 'Test Channel',
name: 'test-channel',
type: 'O',
});
const baseProps = {
channel: mockChannel,
onHide: jest.fn(),
};
let mockConfig: Partial<ClientConfig>;
describe('ChannelSettingsArchiveTab', () => {
const {getHistory} = require('utils/browser_history');
beforeEach(() => {
jest.clearAllMocks();
mockConfig = {
ExperimentalViewArchivedChannels: 'false',
};
jest.spyOn(teams, 'getCurrentTeam').mockReturnValue({
id: 'team1',
name: 'team-name',
} as Team);
const historyPush = jest.fn();
(getHistory as jest.Mock).mockReturnValue({push: historyPush});
});
it('should render the archive button', () => {
renderWithContext(<ChannelSettingsArchiveTab {...baseProps}/>);
// Check that the archive button is rendered
const archiveButton = screen.getByText('Archive this channel');
expect(archiveButton).toBeInTheDocument();
expect(archiveButton).toHaveAttribute('aria-label', 'Archive channel Test Channel');
});
it('should show confirmation modal when archive button is clicked', async () => {
renderWithContext(<ChannelSettingsArchiveTab {...baseProps}/>);
// Click the archive button
await userEvent.click(screen.getByText('Archive this channel'));
// Check that the confirmation modal is shown
expect(screen.getByTestId('archiveChannelConfirmModal')).toBeInTheDocument();
expect(screen.getByText('Archive channel?')).toBeInTheDocument();
});
it('should call deleteChannel and onHide when confirmed', async () => {
const onHide = jest.fn();
renderWithContext(<ChannelSettingsArchiveTab {...{...baseProps, onHide}}/>);
// Click the archive button
await userEvent.click(screen.getByText('Archive this channel'));
// Click the confirm button in the modal
await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
// Check that deleteChannel was called with the channel ID
expect(channelActions.deleteChannel).toHaveBeenCalledWith(mockChannel.id);
// Check that onHide was called
expect(onHide).toHaveBeenCalled();
});
it('should close the confirmation modal when canceled', async () => {
renderWithContext(<ChannelSettingsArchiveTab {...baseProps}/>);
// Click the archive button
await userEvent.click(screen.getByText('Archive this channel'));
// Check that the confirmation modal is shown
expect(screen.getByTestId('archiveChannelConfirmModal')).toBeInTheDocument();
// Click the cancel button in the modal
await userEvent.click(screen.getByTestId('cancel-button'));
// Check that the confirmation modal is hidden
await waitFor(() => {
expect(screen.queryByTestId('archiveChannelConfirmModal')).not.toBeInTheDocument();
});
});
it('should call deleteChannel which handles redirection when archived channel cannot be viewed', async () => {
// Mock the deleteChannel implementation to simulate the action's behavior
const {deleteChannel} = require('actions/views/channel');
deleteChannel.mockImplementationOnce(() => {
return () => {
// The action would handle redirection internally
return {data: true};
};
});
renderWithContext(<ChannelSettingsArchiveTab {...baseProps}/>);
// Click the archive button
await userEvent.click(screen.getByText('Archive this channel'));
// Check that the confirmation modal is shown
expect(screen.getByTestId('archiveChannelConfirmModal')).toBeInTheDocument();
// Click the confirm button in the modal
await userEvent.click(screen.getByText('Confirm'));
// Check that deleteChannel was called with the channel ID
expect(channelActions.deleteChannel).toHaveBeenCalledWith(mockChannel.id);
});
it('should show correct message when archived channels cannot be viewed', async () => {
renderWithContext(<ChannelSettingsArchiveTab {...baseProps}/>);
// Click the archive button
await userEvent.click(screen.getByText('Archive this channel'));
// Check that the confirmation modal message mentions that archived channels cannot be viewed
const modalMessage = screen.getByTestId('archiveChannelConfirmModal');
expect(modalMessage).toBeInTheDocument();
// Check for the specific text content within the modal
// Use the within function to scope the query to just the modal content
const modalBody = screen.getByTestId('archiveChannelConfirmModal').querySelector('#confirmModalBody');
expect(modalBody).toBeInTheDocument();
expect(modalBody).toHaveTextContent(/Archiving a channel removes it from the user interface/);
});
it('should call deleteChannel which handles channel ID validation', async () => {
// Create a channel with an invalid ID
const invalidChannel = {
...mockChannel,
id: 'invalid', // Too short to be a valid channel ID
};
// Mock the deleteChannel implementation to simulate the action's behavior
const {deleteChannel} = require('actions/views/channel');
deleteChannel.mockImplementationOnce(() => {
return () => {
// The action would handle validation internally and return false for invalid IDs
return {data: false};
};
});
renderWithContext(<ChannelSettingsArchiveTab {...{...baseProps, channel: invalidChannel}}/>);
// Click the archive button
await userEvent.click(screen.getByText('Archive this channel'));
// Click the confirm button in the modal
await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
// Check that deleteChannel was called with the invalid channel ID
expect(channelActions.deleteChannel).toHaveBeenCalledWith(invalidChannel.id);
});
});

View file

@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useCallback} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {deleteChannel} from 'actions/views/channel';
import ConfirmationModal from 'components/confirm_modal';
import type {GlobalState} from 'types/store';
type ChannelSettingsArchiveTabProps = {
channel: Channel;
onHide: () => void;
}
function ChannelSettingsArchiveTab({
channel,
onHide,
}: ChannelSettingsArchiveTabProps) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
// Redux selector
const canViewArchivedChannels = useSelector((state: GlobalState) => getConfig(state).ExperimentalViewArchivedChannels === 'true');
const [showArchiveConfirmModal, setShowArchiveConfirmModal] = useState(false);
const handleArchiveChannel = useCallback(() => {
setShowArchiveConfirmModal(true);
}, []);
const doArchiveChannel = async () => {
// Call the delete channel action which handles validation, redirection, and notification sounds
await dispatch(deleteChannel(channel.id));
// Close the modal
onHide();
};
return (
<div className='ChannelSettingsModal__archiveTab'>
<FormattedMessage
id='channel_settings.archive.warning'
defaultMessage="Archiving a channel removes it from the user interface, but doesn't permanently delete the channel. New messages can't be posted to archived channels."
/>
<button
type='button'
className='btn btn-danger'
onClick={handleArchiveChannel}
id='channelSettingsArchiveChannelButton'
aria-label={`Archive channel ${channel.display_name}`}
>
<FormattedMessage
id='channel_settings.archive.button'
defaultMessage='Archive this channel'
/>
</button>
{showArchiveConfirmModal && (
<ConfirmationModal
id='archiveChannelConfirmModal'
show={true}
title={formatMessage({id: 'channel_settings.modal.archiveTitle', defaultMessage: 'Archive channel?'})}
message={
<div>
<p>
<FormattedMessage
id={canViewArchivedChannels ?
'deleteChannelModal.canViewArchivedChannelsWarning' :
'deleteChannelModal.cannotViewArchivedChannelsWarning'
}
defaultMessage="Archiving a channel removes it from the user interface, but doesn't permanently delete the channel. New messages can't be posted to archived channels."
/>
</p>
<p>
<FormattedMessage
id='deleteChannelModal.confirmArchive'
defaultMessage='Are you sure you wish to archive the <strong>{display_name}</strong> channel?'
values={{
display_name: channel.display_name,
strong: (chunks: string) => <strong>{chunks}</strong>,
}}
/>
</p>
</div>
}
confirmButtonText={formatMessage({id: 'channel_settings.modal.confirmArchive', defaultMessage: 'Confirm'})}
onConfirm={doArchiveChannel}
onCancel={() => setShowArchiveConfirmModal(false)}
confirmButtonClass='btn btn-danger'
modalClass='archiveChannelConfirmModal'
focusOriginElement='channelSettingsArchiveChannelButton'
/>
)}
</div>
);
}
export default ChannelSettingsArchiveTab;

View file

@ -0,0 +1,98 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
@import "utils/_animations";
.ChannelSettingsModal__configurationTab {
display: flex;
flex-direction: column;
gap: 16px;
.channel_banner_header {
display: flex;
.channel_banner_header__text {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
span {
&.heading {
color: rgb(var(--center-channel-color-rgb));
font-size: 16px;
}
&.subheading {
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
}
}
}
}
.channel_banner_section_body {
@include fade-in;
display: flex;
width: 100%;
height: 550px;
flex-direction: column;
gap: 16px;
}
.AdvancedTextbox {
width: 390px;
#PreviewInputTextButton {
position: absolute;
z-index: 10;
top: 5px;
right: 5px;
}
#channel_banner_banner_text_textbox {
min-height: 40px;
max-height: 200px;
}
.textbox-preview-area {
height: max-content !important;
max-height: 200px;
overflow-y: auto;
.markdown__heading {
overflow: hidden;
margin: 2px;
font-size: 18px;
text-overflow: ellipsis;
}
}
}
.setting_section {
display: flex;
flex-direction: row;
gap: 12px;
.color-input.input-group {
width: 160px;
}
span.setting_title {
flex: 1;
padding-top: 12px;
color: rgb(var(--center-channel-color-rgb));
font-size: 12px;
font-weight: 600;
}
.setting_body {
flex: 4;
}
.AdvancedTextbox {
margin-top: 0 !important;
}
}
}

View file

@ -0,0 +1,316 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import ChannelSettingsConfigurationTab from './channel_settings_configuration_tab';
// Mock the redux actions and selectors
jest.mock('mattermost-redux/actions/channels', () => ({
patchChannel: jest.fn(),
}));
// Mock the ShowFormat component to make it easier to test
jest.mock('components/advanced_text_editor/show_formatting/show_formatting', () => (
jest.fn().mockImplementation((props) => (
<button
data-testid='mock-show-format'
onClick={props.onClick}
className={props.active ? 'active' : ''}
>
{'Toggle Preview'}
</button>
))
));
// Create a mock channel for testing
const mockChannel = TestHelper.getChannelMock({
id: 'channel1',
team_id: 'team1',
display_name: 'Test Channel',
name: 'test-channel',
purpose: 'Testing purpose',
header: 'Initial header',
type: 'O',
banner_info: {
enabled: false,
text: '',
background_color: '',
},
});
// Create a mock channel with banner enabled
const mockChannelWithBanner = TestHelper.getChannelMock({
id: 'channel1',
team_id: 'team1',
display_name: 'Test Channel',
name: 'test-channel',
purpose: 'Testing purpose',
header: 'Initial header',
type: 'O',
banner_info: {
enabled: true,
text: 'Test banner text',
background_color: '#ff0000',
},
});
const baseProps = {
channel: mockChannel,
setAreThereUnsavedChanges: jest.fn(),
};
describe('ChannelSettingsConfigurationTab', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render with the correct initial values when banner is disabled', () => {
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
// Check that the toggle is not enabled
const toggle = screen.getByTestId('channelBannerToggle-button');
expect(toggle).toBeInTheDocument();
expect(toggle).not.toHaveClass('active');
// Banner text and color inputs should not be visible when banner is disabled
expect(screen.queryByTestId('channel_banner_banner_text_textbox')).not.toBeInTheDocument();
expect(screen.queryByTestId('channel_banner_banner_background_color_picker')).not.toBeInTheDocument();
});
it('should render with the correct initial values when banner is enabled', () => {
renderWithContext(<ChannelSettingsConfigurationTab {...{...baseProps, channel: mockChannelWithBanner}}/>);
// Check that the toggle is enabled
const toggle = screen.getByTestId('channelBannerToggle-button');
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveClass('active');
// Banner text and color inputs should be visible when banner is enabled
expect(screen.getByTestId('channel_banner_banner_text_textbox')).toBeInTheDocument();
expect(screen.getByTestId('channel_banner_banner_text_textbox')).toHaveValue('Test banner text');
// Check that the color picker has the correct value
expect(screen.getByTestId('color-inputColorValue')).toBeInTheDocument();
});
it('should show banner settings when toggle is clicked', async () => {
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
// Initially, banner settings should not be visible
expect(screen.queryByTestId('channel_banner_banner_text_textbox')).not.toBeInTheDocument();
// Click the toggle to enable the banner
await act(async () => {
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
});
// Banner settings should now be visible
expect(screen.getByTestId('channel_banner_banner_text_textbox')).toBeInTheDocument();
expect(screen.getByTestId('color-inputColorValue')).toBeInTheDocument();
});
it('should show SaveChangesPanel when changes are made', async () => {
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
// Initially, SaveChangesPanel should not be visible
expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
// Enable the banner
await act(async () => {
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
});
// Add a small delay to ensure all state updates are processed
await new Promise((resolve) => setTimeout(resolve, 0));
// SaveChangesPanel should now be visible
expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
});
it('should call patchChannel with updated values when Save is clicked', async () => {
const {patchChannel} = require('mattermost-redux/actions/channels');
patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}});
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
// Enable the banner
await act(async () => {
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
});
// Enter banner text
await act(async () => {
const textInput = screen.getByTestId('channel_banner_banner_text_textbox');
await userEvent.clear(textInput);
await userEvent.type(textInput, 'New banner text');
});
// Set banner color
const colorInput = screen.getByTestId('color-inputColorValue');
await act(async () => {
await userEvent.clear(colorInput);
await userEvent.type(colorInput, '#AA00AA');
});
// Click the Save button
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
});
// Verify patchChannel was called with the updated values
expect(patchChannel).toHaveBeenCalledWith('channel1', {
...mockChannel,
banner_info: {
enabled: true,
text: 'New banner text',
background_color: expect.any(String), // The exact color might be hard to test due to the mock
},
});
});
it('should reset form when Reset button is clicked', async () => {
renderWithContext(<ChannelSettingsConfigurationTab {...{...baseProps, channel: mockChannelWithBanner}}/>);
// Change the banner text
await act(async () => {
const textInput = screen.getByTestId('channel_banner_banner_text_textbox');
await userEvent.clear(textInput);
await userEvent.type(textInput, 'Changed banner text');
});
// Add a small delay to ensure all state updates are processed
await new Promise((resolve) => setTimeout(resolve, 0));
// SaveChangesPanel should now be visible
expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
// Click the Reset button
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: 'Reset'}));
});
// Form should be reset to original values
expect(screen.getByTestId('channel_banner_banner_text_textbox')).toHaveValue('Test banner text');
// SaveChangesPanel should be hidden after reset
expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
});
it('should show error when banner text is empty but banner is enabled', async () => {
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
// Enable the banner
await act(async () => {
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
});
// Leave banner text empty
await act(async () => {
const textInput = screen.getByTestId('channel_banner_banner_text_textbox');
await userEvent.clear(textInput);
});
// Click the Save button
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
});
// SaveChangesPanel should show error state
const errorMessage = screen.getByText(/Banner text is required/);
const errorPanel = errorMessage.closest('.SaveChangesPanel');
expect(errorPanel).toHaveClass('error');
});
it('should show error when banner text exceeds character limit', async () => {
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
// Enable the banner
await act(async () => {
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
});
// Create a string that exceeds the allowed character limit
const longText = 'a'.repeat(1025);
// Enter long banner text
await act(async () => {
const textInput = screen.getByTestId('channel_banner_banner_text_textbox');
await userEvent.clear(textInput);
await userEvent.type(textInput, longText);
});
// Add a small delay to ensure all state updates are processed
await new Promise((resolve) => setTimeout(resolve, 0));
// SaveChangesPanel should show error state
const errorMessage = screen.getByText(/There are errors in the form above/);
const errorPanel = errorMessage.closest('.SaveChangesPanel');
expect(errorPanel).toHaveClass('error');
});
it('should toggle preview when preview button is clicked', async () => {
renderWithContext(<ChannelSettingsConfigurationTab {...{...baseProps, channel: mockChannelWithBanner}}/>);
// Initially, preview should not be active
const previewButton = screen.getByTestId('mock-show-format');
expect(previewButton).not.toHaveClass('active');
// Click the preview button
await act(async () => {
await userEvent.click(previewButton);
});
// Preview should now be active
expect(previewButton).toHaveClass('active');
});
it('should disable banner when toggle is clicked while banner is enabled', async () => {
renderWithContext(<ChannelSettingsConfigurationTab {...{...baseProps, channel: mockChannelWithBanner}}/>);
// Initially, banner settings should be visible
expect(screen.getByTestId('channel_banner_banner_text_textbox')).toBeInTheDocument();
// Click the toggle to disable the banner
await act(async () => {
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
});
// Banner settings should now be hidden
expect(screen.queryByTestId('channel_banner_banner_text_textbox')).not.toBeInTheDocument();
});
it('should show error when banner color is empty but banner is enabled', async () => {
const {patchChannel} = require('mattermost-redux/actions/channels');
patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}});
renderWithContext(<ChannelSettingsConfigurationTab {...baseProps}/>);
// Enable the banner
await act(async () => {
await userEvent.click(screen.getByTestId('channelBannerToggle-button'));
});
// Enter banner text but leave color empty
await act(async () => {
const textInput = screen.getByTestId('channel_banner_banner_text_textbox');
await userEvent.clear(textInput);
await userEvent.type(textInput, 'New banner text');
});
// Click the Save button
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
});
// SaveChangesPanel should show error state
const errorMessage = screen.getByText(/Banner color is required/);
const errorPanel = errorMessage.closest('.SaveChangesPanel');
expect(errorPanel).toHaveClass('error');
});
});

View file

@ -0,0 +1,291 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import type {ServerError} from '@mattermost/types/errors';
import {patchChannel} from 'mattermost-redux/actions/channels';
import ColorInput from 'components/color_input';
import type {TextboxElement} from 'components/textbox';
import Toggle from 'components/toggle';
import AdvancedTextbox from 'components/widgets/advanced_textbox/advanced_textbox';
import type {SaveChangesPanelState} from 'components/widgets/modals/components/save_changes_panel';
import SaveChangesPanel from 'components/widgets/modals/components/save_changes_panel';
import './channel_settings_configuration_tab.scss';
const CHANNEL_BANNER_MAX_CHARACTER_LIMIT = 1024;
const CHANNEL_BANNER_MIN_CHARACTER_LIMIT = 0;
const DEFAULT_CHANNEL_BANNER = {
enabled: false,
background_color: '',
text: '',
};
type Props = {
channel: Channel;
setAreThereUnsavedChanges?: (unsaved: boolean) => void;
showTabSwitchError?: boolean;
}
function ChannelSettingsConfigurationTab({channel, setAreThereUnsavedChanges, showTabSwitchError}: Props) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const heading = formatMessage({id: 'channel_banner.label.name', defaultMessage: 'Channel Banner'});
const subHeading = formatMessage({id: 'channel_banner.label.subtext', defaultMessage: 'When enabled, a customized banner will display at the top of the channel.'});
const bannerTextSettingTitle = formatMessage({id: 'channel_banner.banner_text.label', defaultMessage: 'Banner text'});
const bannerColorSettingTitle = formatMessage({id: 'channel_banner.banner_color.label', defaultMessage: 'Banner color'});
const bannerTextPlaceholder = formatMessage({id: 'channel_banner.banner_text.placeholder', defaultMessage: 'Channel banner text'});
const initialBannerInfo = channel.banner_info || DEFAULT_CHANNEL_BANNER;
const [formError, setFormError] = useState('');
const [showBannerTextPreview, setShowBannerTextPreview] = useState(false);
const [updatedChannelBanner, setUpdatedChannelBanner] = useState(initialBannerInfo);
const [requireConfirm, setRequireConfirm] = useState(false);
const [characterLimitExceeded, setCharacterLimitExceeded] = useState(false);
const [saveChangesPanelState, setSaveChangesPanelState] = useState<SaveChangesPanelState>();
// Change handlers
const handleToggle = useCallback(() => {
const newValue = !updatedChannelBanner.enabled;
const toUpdate = {
...updatedChannelBanner,
enabled: newValue,
};
if (!newValue) {
toUpdate.text = initialBannerInfo.text;
toUpdate.background_color = initialBannerInfo.background_color;
}
setUpdatedChannelBanner(toUpdate);
}, [initialBannerInfo, updatedChannelBanner]);
const handleTextChange = useCallback((e: React.ChangeEvent<TextboxElement>) => {
setUpdatedChannelBanner({
...updatedChannelBanner,
text: e.target.value,
});
if (e.target.value.trim().length > CHANNEL_BANNER_MAX_CHARACTER_LIMIT) {
setFormError(formatMessage({
id: 'channel_settings.save_changes_panel.standard_error',
defaultMessage: 'There are errors in the form above',
}));
setCharacterLimitExceeded(true);
} else if (e.target.value.trim().length <= CHANNEL_BANNER_MIN_CHARACTER_LIMIT) {
setFormError(formatMessage({
id: 'channel_settings.save_changes_panel.banner_text.required_error',
defaultMessage: 'Channel banner text cannot be empty when enabled',
}));
setCharacterLimitExceeded(true);
} else {
setFormError('');
setCharacterLimitExceeded(false);
}
}, [formatMessage, updatedChannelBanner]);
const handleColorChange = useCallback((color: string) => {
setUpdatedChannelBanner({
...updatedChannelBanner,
background_color: color,
});
}, [updatedChannelBanner]);
const toggleTextPreview = useCallback(() => setShowBannerTextPreview((show) => !show), []);
const hasUnsavedChanges = useCallback(() => {
return updatedChannelBanner.text !== initialBannerInfo?.text ||
updatedChannelBanner.background_color !== initialBannerInfo?.background_color ||
updatedChannelBanner.enabled !== initialBannerInfo?.enabled;
}, [initialBannerInfo, updatedChannelBanner]);
useEffect(() => {
const unsavedChanges = hasUnsavedChanges();
setRequireConfirm(unsavedChanges);
setAreThereUnsavedChanges?.(unsavedChanges);
}, [hasUnsavedChanges, setAreThereUnsavedChanges]);
const handleServerError = useCallback((err: ServerError) => {
const errorMsg = err.message || formatMessage({id: 'channel_settings.unknown_error', defaultMessage: 'Something went wrong.'});
setFormError(errorMsg);
}, [formatMessage]);
const handleSave = useCallback(async (): Promise<boolean> => {
if (!channel) {
return false;
}
if (updatedChannelBanner.enabled && !updatedChannelBanner.text?.trim()) {
setFormError(formatMessage({
id: 'channel_settings.error_banner_text_required',
defaultMessage: 'Banner text is required',
}));
return false;
}
if (updatedChannelBanner.enabled && !updatedChannelBanner.background_color?.trim()) {
setFormError(formatMessage({
id: 'channel_settings.error_banner_color_required',
defaultMessage: 'Banner color is required',
}));
return false;
}
const updated: Channel = {
...channel,
};
updated.banner_info = {
text: updatedChannelBanner.text,
background_color: updatedChannelBanner.background_color,
enabled: updatedChannelBanner.enabled,
};
const {error} = await dispatch(patchChannel(channel.id, updated));
if (error) {
handleServerError(error as ServerError);
return false;
}
return true;
}, [channel, dispatch, formatMessage, handleServerError, updatedChannelBanner]);
const handleSaveChanges = useCallback(async () => {
const success = await handleSave();
if (!success) {
setSaveChangesPanelState('error');
return;
}
setSaveChangesPanelState('saved');
}, [handleSave]);
const handleCancel = useCallback(() => {
setRequireConfirm(false);
setSaveChangesPanelState(undefined);
setShowBannerTextPreview(false);
setUpdatedChannelBanner(initialBannerInfo);
setFormError('');
setCharacterLimitExceeded(false);
}, [initialBannerInfo]);
const handleClose = useCallback(() => {
setSaveChangesPanelState(undefined);
setRequireConfirm(false);
}, []);
const hasErrors = Boolean(formError) ||
characterLimitExceeded ||
showTabSwitchError;
return (
<div className='ChannelSettingsModal__configurationTab'>
<div className='channel_banner_header'>
<div className='channel_banner_header__text'>
<label
className='Input_legend'
aria-label={heading}
>
{heading}
</label>
<label
className='Input_subheading'
aria-label={heading}
>
{subHeading}
</label>
</div>
<div className='channel_banner_header__toggle'>
<Toggle
id='channelBannerToggle'
ariaLabel={heading}
size='btn-md'
disabled={false}
onToggle={handleToggle}
toggled={updatedChannelBanner.enabled}
tabIndex={0}
toggleClassName='btn-toggle-primary'
/>
</div>
</div>
{
updatedChannelBanner.enabled &&
<div className='channel_banner_section_body'>
{/*Banner text section*/}
<div className='setting_section'>
<span
className='setting_title'
aria-label={bannerTextSettingTitle}
>
{bannerTextSettingTitle}
</span>
<div className='setting_body'>
<AdvancedTextbox
id='channel_banner_banner_text_textbox'
value={updatedChannelBanner.text!}
channelId={channel.id}
onKeyPress={() => {}}
showCharacterCount={true}
useChannelMentions={false}
onChange={handleTextChange}
preview={showBannerTextPreview}
togglePreview={toggleTextPreview}
hasError={characterLimitExceeded}
createMessage={bannerTextPlaceholder}
maxLength={CHANNEL_BANNER_MAX_CHARACTER_LIMIT}
minLength={CHANNEL_BANNER_MIN_CHARACTER_LIMIT}
/>
</div>
</div>
{/*Banner background color section*/}
<div className='setting_section'>
<span
className='setting_title'
aria-label={bannerColorSettingTitle}
>
{bannerColorSettingTitle}
</span>
<div className='setting_body'>
<ColorInput
id='channel_banner_banner_background_color_picker'
onChange={handleColorChange}
value={updatedChannelBanner.background_color || ''}
/>
</div>
</div>
</div>
}
{requireConfirm && (
<SaveChangesPanel
handleSubmit={handleSaveChanges}
handleCancel={handleCancel}
handleClose={handleClose}
tabChangeError={hasErrors}
state={hasErrors ? 'error' : saveChangesPanelState}
customErrorMessage={formError}
cancelButtonText={formatMessage({
id: 'channel_settings.save_changes_panel.reset',
defaultMessage: 'Reset',
})}
/>
)}
</div>
);
}
export default ChannelSettingsConfigurationTab;

View file

@ -0,0 +1,534 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import type {ChannelType} from '@mattermost/types/channels';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import ChannelSettingsInfoTab from './channel_settings_info_tab';
// Mock the redux actions and selectors
jest.mock('mattermost-redux/actions/channels', () => ({
patchChannel: jest.fn(),
updateChannelPrivacy: jest.fn(),
}));
// Mock the ConvertConfirmModal component
jest.mock('components/admin_console/team_channel_settings/convert_confirm_modal', () => {
return jest.fn().mockImplementation(({show, onCancel, onConfirm, displayName}) => {
if (!show) {
return null;
}
return (
<div data-testid='convert-confirm-modal'>
<div>{'Converting '}{displayName}{' to private'}</div>
<button onClick={onCancel}>{'Cancel'}</button>
<button onClick={onConfirm}>{'Yes, Convert Channel'}</button>
</div>
);
});
});
let mockChannelPropertiesPermission = true;
let mockConvertToPublicPermission = true;
let mockConvertToPrivatePermission = true;
jest.mock('mattermost-redux/selectors/entities/roles', () => ({
haveITeamPermission: jest.fn().mockReturnValue(true),
haveIChannelPermission: jest.fn().mockImplementation((state, teamId, channelId, permission: string) => {
if (permission === 'manage_private_channel_properties' || permission === 'manage_public_channel_properties') {
return mockChannelPropertiesPermission;
}
if (permission === 'convert_public_channel_to_private') {
return mockConvertToPrivatePermission;
}
if (permission === 'convert_private_channel_to_public') {
return mockConvertToPublicPermission;
}
return true;
}),
getRoles: jest.fn().mockReturnValue({}),
}));
jest.mock('selectors/views/textbox', () => ({
showPreviewOnChannelSettingsHeaderModal: jest.fn().mockReturnValue(false),
showPreviewOnChannelSettingsPurposeModal: jest.fn().mockReturnValue(false),
}));
jest.mock('actions/views/textbox', () => ({
setShowPreviewOnChannelSettingsHeaderModal: jest.fn(),
setShowPreviewOnChannelSettingsPurposeModal: jest.fn(),
}));
// Mock the isChannelAdmin function
jest.mock('mattermost-redux/utils/user_utils', () => {
const original = jest.requireActual('mattermost-redux/utils/user_utils');
return {
...original,
isChannelAdmin: jest.fn().mockReturnValue(false),
};
});
// Mock the ShowFormat component to make it easier to test
jest.mock('components/advanced_text_editor/show_formatting/show_formatting', () => (
jest.fn().mockImplementation((props) => (
<button
data-testid='mock-show-format'
onClick={props.onClick}
className={props.active ? 'active' : ''}
>
{'Toggle Preview'}
</button>
))
));
// Create a mock channel member
const mockChannelMember = TestHelper.getChannelMembershipMock({
roles: 'channel_user system_admin',
});
// Mock the current user
const mockUser = TestHelper.getUserMock({
id: 'user_id',
roles: 'system_admin',
});
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
...jest.requireActual('mattermost-redux/selectors/entities/channels') as typeof import('mattermost-redux/selectors/entities/channels'),
getChannelMember: jest.fn(() => mockChannelMember),
}));
jest.mock('mattermost-redux/selectors/entities/common', () => {
return {
...jest.requireActual('mattermost-redux/selectors/entities/common') as typeof import('mattermost-redux/selectors/entities/users'),
getCurrentUser: () => mockUser,
};
});
// Create a mock channel for testing
const mockChannel = TestHelper.getChannelMock({
id: 'channel1',
team_id: 'team1',
display_name: 'Test Channel',
name: 'test-channel',
purpose: 'Testing purpose',
header: 'Initial header',
type: 'O',
});
const baseProps = {
channel: mockChannel,
setAreThereUnsavedChanges: jest.fn(),
};
describe('ChannelSettingsInfoTab', () => {
beforeEach(() => {
jest.clearAllMocks();
mockChannelPropertiesPermission = true;
mockConvertToPublicPermission = true;
mockConvertToPrivatePermission = true;
});
it('should render with the correct initial values', () => {
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
// Check that the channel name field has the correct value.
expect(screen.getByRole('textbox', {name: 'Channel name'})).toHaveValue('Test Channel');
// Check that the purpose field has the correct value.
expect(screen.getByTestId('channel_settings_purpose_textbox')).toHaveValue('Testing purpose');
// Check that the header field has the correct value.
expect(screen.getByTestId('channel_settings_header_textbox')).toHaveValue('Initial header');
// Check that the public channel button is selected.
expect(screen.getByRole('button', {name: /Public Channel/}).classList.contains('selected')).toBe(true);
});
it('should show SaveChangesPanel when changes are made', async () => {
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
// Initially, SaveChangesPanel should not be visible.
expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
// Wrap the interaction in act to handle state updates properly
await act(async () => {
// Change the channel name.
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Updated Channel Name');
});
// Add a small delay to ensure all state updates are processed
await new Promise((resolve) => setTimeout(resolve, 0));
// SaveChangesPanel should now be visible.
expect(screen.queryByRole('button', {name: 'Save'})).toBeInTheDocument();
});
it('should call patchChannel with updated values when Save is clicked (non-privacy changes)', async () => {
const {patchChannel} = require('mattermost-redux/actions/channels');
patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}});
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
// Wrap all user interactions in act to handle state updates properly
await act(async () => {
// Change the channel name.
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Updated Channel Name');
// Change the channel purpose.
const purposeInput = screen.getByTestId('channel_settings_purpose_textbox');
await userEvent.clear(purposeInput);
await userEvent.type(purposeInput, 'Updated purpose');
// Change the channel header.
const headerInput = screen.getByTestId('channel_settings_header_textbox');
await userEvent.clear(headerInput);
await userEvent.type(headerInput, 'Updated header');
});
// Add a small delay to ensure all state updates are processed
await new Promise((resolve) => setTimeout(resolve, 0));
// Click the Save button in the SaveChangesPanel.
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
});
// Verify patchChannel was called with the updated values (without type change).
expect(patchChannel).toHaveBeenCalledWith('channel1', {
...mockChannel,
display_name: 'Updated Channel Name',
name: 'updated-channel-name',
purpose: 'Updated purpose',
header: 'Updated header',
});
});
it('should reset form when Reset button is clicked', async () => {
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
// Wrap the interaction in act to handle state updates properly
await act(async () => {
// Change the channel name.
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Updated Channel Name');
});
// Add a small delay to ensure all state updates are processed
await new Promise((resolve) => setTimeout(resolve, 0));
// SaveChangesPanel should now be visible.
expect(screen.queryByRole('button', {name: 'Save'})).toBeInTheDocument();
// Click the Reset button.
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: 'Reset'}));
});
// Form should be reset to original values.
expect(screen.getByRole('textbox', {name: 'Channel name'})).toHaveValue('Test Channel');
// SaveChangesPanel should be hidden after reset.
expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
});
it('should show error state when save fails', async () => {
const {patchChannel} = require('mattermost-redux/actions/channels');
patchChannel.mockReturnValue({type: 'MOCK_ACTION', error: {message: 'Error saving channel'}});
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
// Wrap the interaction in act to handle state updates properly
await act(async () => {
// Change the channel name.
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Updated Channel Name');
});
// Add a small delay to ensure all state updates are processed
await new Promise((resolve) => setTimeout(resolve, 0));
// Click the Save button.
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
});
// SaveChangesPanel should show 'error' state.
const errorMessage = screen.getByText(/There are errors in the form above/);
const errorPanel = errorMessage.closest('.SaveChangesPanel');
expect(errorPanel).toHaveClass('error');
});
// Instead of clicking a non-existent element to trigger a channel name error,
// simulate an invalid input by clearing the channel name (which is required).
it('should show error when channel name field has an error', async () => {
renderWithContext(
<ChannelSettingsInfoTab
{...baseProps}
/>,
);
// Wrap the interaction in act to handle state updates properly
await act(async () => {
// Clear the channel name to simulate an error.
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Updated Channel Name');
await userEvent.clear(nameInput);
nameInput.blur();
});
// Add a small delay to ensure all state updates are processed
await new Promise((resolve) => setTimeout(resolve, 0));
// SaveChangesPanel should show error state.
const errorMessage = screen.getByText(/There are errors in the form above/);
const errorPanel = errorMessage.closest('.SaveChangesPanel');
expect(errorPanel).toHaveClass('error');
});
it('should show error when purpose exceeds character limit', async () => {
renderWithContext(
<ChannelSettingsInfoTab
{...baseProps}
/>,
);
// Create a string that exceeds the allowed character limit
const longPurpose = 'a'.repeat(1025);
// Wrap the interaction in act to handle state updates properly
await act(async () => {
const purposeInput = screen.getByTestId('channel_settings_purpose_textbox');
await userEvent.clear(purposeInput);
await userEvent.type(purposeInput, longPurpose);
});
// Add a small delay to ensure all state updates are processed
await new Promise((resolve) => setTimeout(resolve, 0));
// SaveChangesPanel should show error state.
const errorMessage = screen.getByText(/There are errors in the form above/);
const errorPanel = errorMessage.closest('.SaveChangesPanel');
expect(errorPanel).toHaveClass('error');
});
it('should show error when header exceeds character limit', async () => {
renderWithContext(
<ChannelSettingsInfoTab
{...baseProps}
/>,
);
// Create a string that exceeds the header character limit.
const longHeader = 'a'.repeat(1025);
// Wrap the interaction in act to handle state updates properly
await act(async () => {
const headerInput = screen.getByTestId('channel_settings_header_textbox');
await userEvent.clear(headerInput);
await userEvent.type(headerInput, longHeader);
});
// Add a small delay to ensure all state updates are processed
await new Promise((resolve) => setTimeout(resolve, 0));
// SaveChangesPanel should show error state.
const errorMessage = screen.getByText(/There are errors in the form above/);
const errorPanel = errorMessage.closest('.SaveChangesPanel');
expect(errorPanel).toHaveClass('error');
});
it('should render ChannelNameFormField and AdvancedTextbox as readOnly when user does not have permission', () => {
mockChannelPropertiesPermission = false;
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
// Check that the name input is disabled
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
expect(nameInput).toBeDisabled();
// When in readOnly mode, the preview toggle button should not be present
expect(screen.queryByTestId('mock-show-format')).not.toBeInTheDocument();
});
it('should render ChannelNameFormField and AdvancedTextbox as not readOnly when user has permission', () => {
mockChannelPropertiesPermission = true;
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
// Check that the name input is not disabled
const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
expect(nameInput).not.toBeDisabled();
// When not in readOnly mode, at least one preview toggle button should be present
const previewButtons = screen.queryAllByTestId('mock-show-format');
expect(previewButtons.length).toBeGreaterThan(0);
});
it('should not allow channel type change when user lacks permissions', async () => {
// Set permissions to false
mockConvertToPublicPermission = false;
mockConvertToPrivatePermission = false;
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
// Private channel button should be disabled
const privateButton = screen.getByRole('button', {name: /Private Channel/});
expect(privateButton).toHaveClass('disabled');
});
it('should allow channel type change UI when user has permission to convert to private', async () => {
// Set convert permission to true
mockConvertToPrivatePermission = true;
mockConvertToPublicPermission = true;
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
// Private channel button should not be disabled
const privateButton = screen.getByRole('button', {name: /Private Channel/});
expect(privateButton).not.toBeDisabled();
// Should be able to change to private in the UI
await userEvent.click(privateButton);
// Verify the private button is now selected
expect(privateButton).toHaveClass('selected');
});
it('should never allow conversion from private to public', async () => {
// Set convert permission to true (even with permission, it should be prevented)
mockConvertToPublicPermission = true;
// Start with a private channel
const privateChannel = {
...mockChannel,
type: 'P' as ChannelType,
};
renderWithContext(
<ChannelSettingsInfoTab
{...baseProps}
channel={privateChannel}
/>,
);
// Public channel button should be disabled regardless of permissions
const publicButton = screen.getByRole('button', {name: /Public Channel/});
expect(publicButton).toHaveClass('disabled');
// Private button should be selected
const privateButton = screen.getByRole('button', {name: /Private Channel/});
expect(privateButton).toHaveClass('selected');
});
it('should show ConvertConfirmModal when converting from public to private', async () => {
mockConvertToPrivatePermission = true;
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
// Change to private channel
const privateButton = screen.getByRole('button', {name: /Private Channel/});
await userEvent.click(privateButton);
// Click Save button
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
});
// Verify the modal is shown
expect(screen.getByTestId('convert-confirm-modal')).toBeInTheDocument();
});
it('should convert channel when confirming in ConvertConfirmModal', async () => {
mockConvertToPrivatePermission = true;
const {updateChannelPrivacy} = require('mattermost-redux/actions/channels');
updateChannelPrivacy.mockReturnValue({type: 'MOCK_ACTION', data: {}});
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
// Change to private channel
const privateButton = screen.getByRole('button', {name: /Private Channel/});
await userEvent.click(privateButton);
// Click Save button to show modal
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
});
// Click confirm button in modal
await act(async () => {
await userEvent.click(screen.getByText(/Yes, Convert Channel/i));
});
// Verify updateChannelPrivacy was called
expect(updateChannelPrivacy).toHaveBeenCalledWith('channel1', 'P');
});
it('should not convert channel when canceling in ConvertConfirmModal', async () => {
mockConvertToPrivatePermission = true;
const {updateChannelPrivacy} = require('mattermost-redux/actions/channels');
updateChannelPrivacy.mockReturnValue({type: 'MOCK_ACTION', data: {}});
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
// Change to private channel
const privateButton = screen.getByRole('button', {name: /Private Channel/});
await userEvent.click(privateButton);
// Click Save button to show modal
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
});
// Click cancel button in modal
await act(async () => {
await userEvent.click(screen.getByText(/Cancel/i));
});
// Verify updateChannelPrivacy was not called
expect(updateChannelPrivacy).not.toHaveBeenCalled();
});
it('should handle errors when converting channel privacy', async () => {
mockConvertToPrivatePermission = true;
const {updateChannelPrivacy} = require('mattermost-redux/actions/channels');
updateChannelPrivacy.mockReturnValue({
type: 'MOCK_ACTION',
error: {message: 'Error changing privacy'},
});
renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
// Change to private channel
const privateButton = screen.getByRole('button', {name: /Private Channel/});
await userEvent.click(privateButton);
// Click Save button to show modal
await act(async () => {
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
});
// Click confirm button in modal
await act(async () => {
await userEvent.click(screen.getByText(/Yes, Convert Channel/i));
});
// Verify error state is shown
expect(screen.getByText(/There are errors in the form above/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,471 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState, useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {Channel, ChannelType} from '@mattermost/types/channels';
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 {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {
setShowPreviewOnChannelSettingsHeaderModal,
setShowPreviewOnChannelSettingsPurposeModal,
} from 'actions/views/textbox';
import {
showPreviewOnChannelSettingsHeaderModal,
showPreviewOnChannelSettingsPurposeModal,
} from 'selectors/views/textbox';
import ConvertConfirmModal from 'components/admin_console/team_channel_settings/convert_confirm_modal';
import ChannelNameFormField from 'components/channel_name_form_field/channel_name_form_field';
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';
import PublicPrivateSelector from 'components/widgets/public-private-selector/public-private-selector';
import Constants from 'utils/constants';
import type {GlobalState} from 'types/store';
type ChannelSettingsInfoTabProps = {
channel: Channel;
onCancel?: () => void;
setAreThereUnsavedChanges?: (unsaved: boolean) => void;
showTabSwitchError?: boolean;
};
function ChannelSettingsInfoTab({
channel,
onCancel,
setAreThereUnsavedChanges,
showTabSwitchError,
}: ChannelSettingsInfoTabProps) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const shouldShowPreviewPurpose = useSelector(showPreviewOnChannelSettingsPurposeModal);
const shouldShowPreviewHeader = useSelector(showPreviewOnChannelSettingsHeaderModal);
// Permissions for transforming channel type
const canConvertToPrivate = useSelector((state: GlobalState) =>
haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE),
);
const canConvertToPublic = useSelector((state: GlobalState) =>
haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CONVERT_PRIVATE_CHANNEL_TO_PUBLIC),
);
// Permissions for managing channel (name, header, purpose)
const channelPropertiesPermission = channel.type === Constants.PRIVATE_CHANNEL ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES;
const canManageChannelProperties = useSelector((state: GlobalState) =>
haveIChannelPermission(state, channel.team_id, channel.id, channelPropertiesPermission),
);
// Constants
const HEADER_MAX_LENGTH = 1024;
// Internal state variables
const [internalUrlError, setUrlError] = useState('');
const [channelNameError, setChannelNameError] = useState('');
const [characterLimitExceeded, setCharacterLimitExceeded] = useState(false);
const [showConvertConfirmModal, setShowConvertConfirmModal] = useState(false);
// Removed switchingTabsWithUnsaved state as we now use the showTabSwitchError prop directly
// The fields we allow editing
const [displayName, setDisplayName] = useState(channel?.display_name ?? '');
const [channelUrl, setChannelURL] = useState(channel?.name ?? '');
const [channelPurpose, setChannelPurpose] = useState(channel.purpose ?? '');
const [channelHeader, setChannelHeader] = useState(channel?.header ?? '');
const [channelType, setChannelType] = useState<ChannelType>(channel?.type as ChannelType ?? Constants.OPEN_CHANNEL as ChannelType);
// UI Feedback: errors, states
const [formError, setFormError] = useState('');
// SaveChangesPanel state
const [saveChangesPanelState, setSaveChangesPanelState] = useState<SaveChangesPanelState>();
// Handler for channel name validation errors
const handleChannelNameError = useCallback((isError: boolean, errorMessage?: string) => {
setChannelNameError(errorMessage || '');
// If there's an error, update the error to show in the SaveChangesPanel
if (isError && errorMessage) {
setFormError(errorMessage);
} else if (formError === channelNameError) {
// Only clear error if it's the same as the channel name error
setFormError('');
}
}, [channelNameError, formError, setFormError]);
// Update parent component when changes occur
useEffect(() => {
// Calculate unsaved changes directly
const unsavedChanges = channel ? (
displayName !== channel.display_name ||
channelUrl !== channel.name ||
channelPurpose !== channel.purpose ||
channelHeader !== channel.header ||
channelType !== channel.type
) : false;
setAreThereUnsavedChanges?.(unsavedChanges);
}, [channel, displayName, channelUrl, channelPurpose, channelHeader, channelType, setAreThereUnsavedChanges]);
const handleURLChange = useCallback((newURL: string) => {
if (internalUrlError) {
setFormError('');
setSaveChangesPanelState(undefined);
setUrlError('');
}
setChannelURL(newURL);
}, [internalUrlError]);
const togglePurposePreview = useCallback(() => {
dispatch(setShowPreviewOnChannelSettingsPurposeModal(!shouldShowPreviewPurpose));
}, [dispatch, shouldShowPreviewPurpose]);
const toggleHeaderPreview = useCallback(() => {
dispatch(setShowPreviewOnChannelSettingsHeaderModal(!shouldShowPreviewHeader));
}, [dispatch, shouldShowPreviewHeader]);
const handleChannelTypeChange = (type: ChannelType) => {
// Never allow conversion from private to public, regardless of permissions
if (channel.type === Constants.PRIVATE_CHANNEL && type === Constants.OPEN_CHANNEL) {
return;
}
// Check if user has permission to convert from public to private
if (channel.type === Constants.OPEN_CHANNEL && type === Constants.PRIVATE_CHANNEL && !canConvertToPrivate) {
return;
}
setChannelType(type);
setFormError('');
};
const handleHeaderChange = useCallback((e: React.ChangeEvent<TextboxElement>) => {
const newValue = e.target.value;
// Update the header value
setChannelHeader(newValue);
// Check for character limit
if (newValue.length > HEADER_MAX_LENGTH) {
setFormError(formatMessage({
id: 'edit_channel_header_modal.error',
defaultMessage: 'The text entered exceeds the character limit. The channel header is limited to {maxLength} characters.',
}, {
maxLength: HEADER_MAX_LENGTH,
}));
} else if (formError && !channelNameError) {
// Only clear form error if there's no channel name error
// This prevents clearing channel name errors when editing the header
setFormError('');
}
}, [HEADER_MAX_LENGTH, formError, channelNameError, setFormError, formatMessage]);
const handlePurposeChange = useCallback((e: React.ChangeEvent<TextboxElement>) => {
const newValue = e.target.value;
// Update the purpose value
setChannelPurpose(newValue);
// Check for character limit
if (newValue.length > Constants.MAX_CHANNELPURPOSE_LENGTH) {
setFormError(formatMessage({
id: 'channel_settings.error_purpose_length',
defaultMessage: 'The text entered exceeds the character limit. The channel purpose is limited to {maxLength} characters.',
}, {
maxLength: Constants.MAX_CHANNELPURPOSE_LENGTH,
}));
} else if (formError && !channelNameError) {
// Only clear server error if there's no channel name error
// This prevents clearing channel name errors when editing the purpose
setFormError('');
}
}, [formError, channelNameError, setFormError, formatMessage]);
const handleServerError = (err: ServerError) => {
const errorMsg = err.message || formatMessage({id: 'channel_settings.unknown_error', defaultMessage: 'Something went wrong.'});
setFormError(errorMsg);
setSaveChangesPanelState('error');
// Check if the error is related to a URL conflict
if (err.message && (
err.message.toLowerCase().includes('url') ||
err.message.toLowerCase().includes('name') ||
err.message.toLowerCase().includes('already exists')
)) {
setUrlError(errorMsg); // Set the URL error to show in the URL input
}
};
// Validate & Save - using useCallback to ensure it has the latest state values
const handleSave = useCallback(async (): Promise<boolean> => {
if (!channel) {
return false;
}
if (!displayName.trim()) {
setFormError(formatMessage({
id: 'channel_settings.error_display_name_required',
defaultMessage: 'Channel name is required',
}));
return false;
}
// write the code to validate if the channel is changing from public to private
if (channel.type === Constants.OPEN_CHANNEL && channelType === Constants.PRIVATE_CHANNEL) {
const {error} = await dispatch(updateChannelPrivacy(channel.id, General.PRIVATE_CHANNEL));
if (error) {
handleServerError(error as ServerError);
return false;
}
}
// Build updated channel object
const updated: Channel = {
...channel,
display_name: displayName.trim(),
name: channelUrl.trim(),
purpose: channelPurpose.trim(),
header: channelHeader.trim(),
};
const {error} = await dispatch(patchChannel(channel.id, updated));
if (error) {
handleServerError(error as ServerError);
return false;
}
return true;
}, [channel, displayName, channelUrl, channelPurpose, channelHeader, channelType, setFormError, handleServerError]);
// Handle save changes panel actions
const handleSaveChanges = useCallback(async () => {
// Check if privacy is changing from public to private
const isPrivacyChanging = channel.type === Constants.OPEN_CHANNEL &&
channelType === Constants.PRIVATE_CHANNEL;
// If privacy is changing, show confirmation modal
if (isPrivacyChanging) {
setShowConvertConfirmModal(true);
return;
}
// Otherwise proceed with normal save
const success = await handleSave();
if (!success) {
setSaveChangesPanelState('error');
return;
}
setSaveChangesPanelState('saved');
}, [channel, channelType, handleSave]);
const handleClose = useCallback(() => {
setSaveChangesPanelState(undefined);
}, []);
const hideConvertConfirmModal = useCallback(() => {
setShowConvertConfirmModal(false);
}, []);
const handleCancel = useCallback(() => {
// First, hide the panel immediately to prevent further interactions
setSaveChangesPanelState(undefined);
// Then reset all form fields to their original values
setDisplayName(channel?.display_name ?? '');
setChannelURL(channel?.name ?? '');
setChannelPurpose(channel?.purpose ?? '');
setChannelHeader(channel?.header ?? '');
setChannelType(channel?.type as ChannelType ?? Constants.OPEN_CHANNEL as ChannelType);
// Clear errors
setUrlError('');
setFormError('');
setCharacterLimitExceeded(false);
setChannelNameError('');
// If parent provided an onCancel callback, call it
if (onCancel) {
onCancel();
}
}, [channel, onCancel, setFormError]);
// Calculate if there are errors
const hasErrors = Boolean(formError) ||
characterLimitExceeded ||
Boolean(channelNameError) ||
Boolean(showTabSwitchError) ||
Boolean(internalUrlError);
// Memoize the calculation for whether to show the save changes panel
const shouldShowPanel = useMemo(() => {
const unsavedChanges = channel ? (
displayName !== channel.display_name ||
channelUrl !== channel.name ||
channelPurpose !== channel.purpose ||
channelHeader !== channel.header ||
channelType !== channel.type
) : false;
return unsavedChanges || saveChangesPanelState === 'saved';
}, [channel, displayName, channelUrl, channelPurpose, channelHeader, channelType, saveChangesPanelState]);
return (
<div className='ChannelSettingsModal__infoTab'>
{/* ConvertConfirmModal for channel privacy changes */}
<ConvertConfirmModal
show={showConvertConfirmModal}
onCancel={hideConvertConfirmModal}
onConfirm={async () => {
hideConvertConfirmModal();
const success = await handleSave();
if (!success) {
setSaveChangesPanelState('error');
return;
}
setSaveChangesPanelState('saved');
}}
displayName={channel?.display_name || ''}
toPublic={false} // Always false since we're only converting from public to private
/>
{/* Channel Name Section*/}
<label
htmlFor='input_channel-settings-name'
className='Input_legend'
>
{formatMessage({id: 'channel_settings.label.name', defaultMessage: 'Channel Name'})}
</label>
<ChannelNameFormField
value={displayName}
name='channel-settings-name'
placeholder={formatMessage({
id: 'channel_settings_modal.name.placeholder',
defaultMessage: 'Enter a name for your channel',
})}
onDisplayNameChange={(name) => {
setDisplayName(name);
}}
onURLChange={handleURLChange}
onErrorStateChange={handleChannelNameError}
urlError={internalUrlError}
currentUrl={channelUrl}
readOnly={!canManageChannelProperties}
/>
{/* Channel Type Section*/}
<PublicPrivateSelector
className='ChannelSettingsModal__typeSelector'
selected={channelType}
publicButtonProps={{
title: formatMessage({id: 'channel_modal.type.public.title', defaultMessage: 'Public Channel'}),
description: formatMessage({id: 'channel_modal.type.public.description', defaultMessage: 'Anyone can join'}),
// Always disable public button if current channel is private, regardless of permissions
disabled: channel.type === Constants.PRIVATE_CHANNEL || !canConvertToPublic,
}}
privateButtonProps={{
title: formatMessage({id: 'channel_modal.type.private.title', defaultMessage: 'Private Channel'}),
description: formatMessage({id: 'channel_modal.type.private.description', defaultMessage: 'Only invited members'}),
disabled: !canConvertToPrivate,
}}
onChange={handleChannelTypeChange}
/>
{/* Purpose Section*/}
<AdvancedTextbox
id='channel_settings_purpose_textbox'
value={channelPurpose}
channelId={channel.id}
onChange={handlePurposeChange}
createMessage={formatMessage({
id: 'channel_settings_modal.purpose.placeholder',
defaultMessage: 'Enter a purpose for this channel',
})}
maxLength={Constants.MAX_CHANNELPURPOSE_LENGTH}
preview={shouldShowPreviewPurpose}
togglePreview={togglePurposePreview}
useChannelMentions={false}
onKeyPress={() => {}}
descriptionMessage={formatMessage({
id: 'channel_settings.purpose.description',
defaultMessage: 'Describe how this channel should be used.',
})}
hasError={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH}
errorMessage={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH ? formatMessage({
id: 'channel_settings.error_purpose_length',
defaultMessage: 'The channel purpose exceeds the maximum character limit of {maxLength} characters.',
}, {
maxLength: Constants.MAX_CHANNELPURPOSE_LENGTH,
}) : undefined
}
showCharacterCount={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH}
readOnly={!canManageChannelProperties}
name={formatMessage({id: 'channel_settings.purpose.label', defaultMessage: 'Channel Purpose'})}
/>
{/* Channel Header Section*/}
<AdvancedTextbox
id='channel_settings_header_textbox'
value={channelHeader}
channelId={channel.id}
onChange={handleHeaderChange}
createMessage={formatMessage({
id: 'channel_settings_modal.header.placeholder',
defaultMessage: 'Enter a header description or important links',
})}
maxLength={HEADER_MAX_LENGTH}
preview={shouldShowPreviewHeader}
togglePreview={toggleHeaderPreview}
useChannelMentions={false}
onKeyPress={() => {}}
descriptionMessage={formatMessage({
id: 'channel_settings.purpose.header',
defaultMessage: 'This is the text that will appear in the header of the channel beside the channel name. You can use markdown to include links by typing [Link Title](http://example.com).',
})}
hasError={channelHeader.length > HEADER_MAX_LENGTH}
errorMessage={channelHeader.length > HEADER_MAX_LENGTH ? formatMessage({
id: 'edit_channel_header_modal.error',
defaultMessage: 'The channel header exceeds the maximum character limit of {maxLength} characters.',
}, {
maxLength: HEADER_MAX_LENGTH,
}) : undefined
}
showCharacterCount={channelHeader.length > HEADER_MAX_LENGTH}
readOnly={!canManageChannelProperties}
name={formatMessage({id: 'channel_settings.header.label', defaultMessage: 'Channel Header'})}
/>
{/* SaveChangesPanel for unsaved changes */}
{shouldShowPanel && (
<SaveChangesPanel
handleSubmit={handleSaveChanges}
handleCancel={handleCancel}
handleClose={handleClose}
tabChangeError={hasErrors}
state={hasErrors ? 'error' : saveChangesPanelState}
{...(!showTabSwitchError && { // for swowTabShiwthError use the default message
customErrorMessage: formatMessage({
id: 'channel_settings.save_changes_panel.standard_error',
defaultMessage: 'There are errors in the form above',
}),
})}
cancelButtonText={formatMessage({
id: 'channel_settings.save_changes_panel.reset',
defaultMessage: 'Reset',
})}
/>
)}
</div>
);
}
export default ChannelSettingsInfoTab;

View file

@ -0,0 +1,232 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.ChannelSettingsModal {
label.Input_legend {
margin-bottom: 4px;
color: var(--center-channel-color);
font-family: Metropolis, sans-serif;
font-size: 16px;
font-weight: 600;
line-height: 20px;
}
label.Input_subheading {
color: rgba(var(--center-channel-color-rgb), 0.64);
font-family: Open Sans, sans-serif;
font-size: 12px;
font-weight: 400;
}
.GenericModal__wrapper {
display: flex;
overflow: visible;
max-width: 1200px;
max-height: 90vh;
flex-direction: column;
border: var(--border-default);
border-radius: var(--radius-l);
box-shadow: var(--elevation-6);
.modal-body {
display: flex;
overflow: visible;
width: auto;
min-height: 150px;
flex-direction: column;
margin: 0;
gap: 24px;
}
}
&__bodyWrapper {
display: flex;
width: 100%;
max-width: 1024px;
flex-direction: column;
gap: 24px;
}
.settings-table {
display: flex;
flex-direction: row;
.settings-links {
min-width: 200px;
}
.settings-content {
display: flex;
overflow: visible !important; // This is necessary to override the overflow hidden from the modal for emoji list
flex: 1;
flex-direction: column;
.AdvancedTextbox {
position: relative;
}
.channel-settings-name-container, .AdvancedTextbox {
margin-top: 24px;
}
}
}
&__infoTab {
display: flex;
flex-direction: column;
padding-bottom: 60px;
#PreviewInputTextButton {
position: absolute;
z-index: 10;
top: 5px;
right: 5px;
}
// This is a workaround forcing styles to override the ones from the textbox component that affect post input
.textbox-preview-area, #channel_settings_purpose_textbox, #channel_settings_header_textbox {
height: 80px !important;
border: none !important;
box-shadow: none !important;
}
// forcing z-index on the emoji list to be on top of the modal
.suggestion-list {
z-index: 100000;
}
.textbox-preview-area {
box-shadow: none;
}
}
&__typeSelector {
margin-top: 16px;
}
&__purposeContainer, &__headerContainer {
margin-top: 28px;
textarea {
width: 100%;
min-height: 8em;
max-height: 60vh;
box-sizing: border-box;
padding: 12px 16px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
background: var(--center-channel-bg);
color: var(--center-channel-color);
font-size: 14px;
line-height: 20px;
resize: none;
&:hover {
border-color: rgba(var(--center-channel-color-rgb), 0.48);
}
&:focus {
border-color: var(--button-bg);
box-shadow: inset 0 0 0 1px var(--button-bg);
}
&.with-error {
border-color: var(--error-text);
&:focus {
box-shadow: inset 0 0 0 1px var(--error-text);
}
}
}
}
&__headerContainer {
margin-top: 28px;
&--preview-button {
display: flex;
align-items: center;
padding: 4px 8px;
border: none;
background: transparent;
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: pointer;
&:hover {
color: rgba(var(--center-channel-color-rgb), 0.72);
}
i {
font-size: 18px;
}
}
.markdown-preview-wrapper {
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
}
}
&__archiveTab {
display: flex;
flex-direction: column;
gap: 16px;
.btn-danger {
align-self: flex-start;
}
}
@media screen and (max-width: 768px) {
max-width: 100%;
margin: 0;
.modal-content {
display: flex;
height: 100vh;
max-height: unset;
flex-direction: column;
border-radius: unset;
}
}
@media screen and (max-height: 900px) and (min-width: 768px) {
.modal-content {
max-height: 90vh;
}
}
@media screen and (max-height: 600px) {
.modal-content,
.GenericModal__wrapper {
max-height: 85vh !important;
}
}
}
// styles for the archive channel confirm modal
.archiveChannelConfirmModal {
display: flex;
flex-direction: column;
justify-content: center;
.GenericModal__header {
display: flex;
justify-content: center;
h1#genericModalLabel {
width: auto;
}
}
.GenericModal__body {
.ConfirmModal__body {
text-align: center;
}
}
.ConfirmModal__footer {
display: flex;
justify-content: center;
gap: 8px;
}
}

View file

@ -0,0 +1,293 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import type {DeepPartial} from '@mattermost/types/utilities';
import {renderWithContext} from 'tests/react_testing_utils';
import type {GlobalState} from 'types/store';
import ChannelSettingsModal from './channel_settings_modal';
// Variables to control permission check results in tests
let mockPrivateChannelPermission = true;
let mockPublicChannelPermission = true;
// Mock the redux selectors
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
getChannel: jest.fn().mockImplementation((state, channelId) => {
// Return a mock channel based on the channelId
return {
id: channelId,
team_id: 'team1',
display_name: 'Test Channel',
name: channelId === 'default_channel' ? 'town-square' : 'test-channel',
purpose: 'Testing purpose',
header: 'Channel header',
type: mockChannelType, // Use a variable to control the channel type
create_at: 0,
update_at: 0,
delete_at: 0,
last_post_at: 0,
total_msg_count: 0,
extra_update_at: 0,
creator_id: 'creator1',
last_root_post_at: 0,
scheme_id: '',
group_constrained: false,
};
}),
}));
// Mock the roles selector which is used for permission checks
jest.mock('mattermost-redux/selectors/entities/roles', () => ({
haveIChannelPermission: jest.fn().mockImplementation((state, teamId, channelId, permission) => {
// Return different values based on the permission being checked
if (permission === 'delete_private_channel') {
return mockPrivateChannelPermission;
}
if (permission === 'delete_public_channel') {
return mockPublicChannelPermission;
}
return true;
}),
}));
// Mock the child components to simplify testing
jest.mock('./channel_settings_info_tab', () => {
return function MockInfoTab(): JSX.Element {
return <div data-testid='info-tab'>{'Info Tab Content'}</div>;
};
});
jest.mock('./channel_settings_configuration_tab', () => {
return function MockConfigTab(): JSX.Element {
return <div data-testid='config-tab'>{'Configuration Tab Content'}</div>;
};
});
jest.mock('./channel_settings_archive_tab', () => {
return function MockArchiveTab(): JSX.Element {
return <div data-testid='archive-tab'>{'Archive Tab Content'}</div>;
};
});
// Define the tab type for the settings sidebar
type TabType = {
name: string;
uiName: string;
display?: boolean;
};
// Variable to control the channel type in tests
let mockChannelType = 'O';
// Mock the settings sidebar
jest.mock('components/settings_sidebar', () => {
return function MockSettingsSidebar({tabs, activeTab, updateTab}: {tabs: TabType[]; activeTab: string; updateTab: (tab: string) => void}): JSX.Element {
return (
<div data-testid='settings-sidebar'>
{tabs.filter((tab) => tab.display !== false).map((tab) => (
<button
data-testid={`${tab.name}-tab-button`}
key={tab.name}
role='tab'
aria-selected={activeTab === tab.name}
aria-label={tab.name}
onClick={() => updateTab(tab.name)}
>
{tab.uiName}
</button>
))}
</div>
);
};
});
const baseProps = {
channelId: 'channel1',
isOpen: true,
onExited: jest.fn(),
focusOriginElement: 'button1',
};
describe('ChannelSettingsModal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockChannelType = 'O'; // Default to public channel
mockPrivateChannelPermission = true;
mockPublicChannelPermission = true;
});
it('should render the modal with correct header text', async () => {
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
expect(screen.getByText('Channel Settings')).toBeInTheDocument();
});
it('should render Info tab by default', async () => {
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
// Wait for the lazy-loaded components
await waitFor(() => {
expect(screen.getByTestId('info-tab')).toBeInTheDocument();
});
});
it('should switch tabs when clicked', async () => {
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
// Wait for the sidebar to load
await waitFor(() => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// Initially the info tab should be active
expect(screen.getByTestId('info-tab')).toBeInTheDocument();
// Find and click the archive tab
const archiveTab = screen.getByRole('tab', {name: 'archive'});
await userEvent.click(archiveTab);
// Now the archive tab should be visible
expect(screen.getByTestId('archive-tab')).toBeInTheDocument();
});
it('should not show archive tab for default channel', async () => {
renderWithContext(
<ChannelSettingsModal
{...{...baseProps, channelId: 'default_channel'}}
/>,
);
// Wait for the sidebar to load
await waitFor(() => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// The info tab should be visible
expect(screen.getByTestId('info-tab')).toBeInTheDocument();
// The archive tab should not be in the document
expect(screen.queryByRole('tab', {name: 'archive'})).not.toBeInTheDocument();
});
it('should show archive tab for public channel when user has permission', async () => {
mockChannelType = 'O';
mockPublicChannelPermission = true;
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
// Wait for the sidebar to load
await waitFor(() => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// The archive tab should be visible
expect(screen.getByRole('tab', {name: 'archive'})).toBeInTheDocument();
});
it('should not show archive tab for public channel when user does not have permission', async () => {
mockChannelType = 'O';
mockPublicChannelPermission = false;
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
// Wait for the sidebar to load
await waitFor(() => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// The archive tab should not be in the document
expect(screen.queryByRole('tab', {name: 'archive'})).not.toBeInTheDocument();
});
it('should show archive tab for private channel when user has permission', async () => {
mockChannelType = 'P';
mockPrivateChannelPermission = true;
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
// Wait for the sidebar to load
await waitFor(() => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// The archive tab should be visible
expect(screen.getByRole('tab', {name: 'archive'})).toBeInTheDocument();
});
it('should not show archive tab for private channel when user does not have permission', async () => {
mockChannelType = 'P';
mockPrivateChannelPermission = false;
renderWithContext(<ChannelSettingsModal {...baseProps}/>);
// Wait for the sidebar to load
await waitFor(() => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// The archive tab should not be in the document
expect(screen.queryByRole('tab', {name: 'archive'})).not.toBeInTheDocument();
});
it('should not show configuration tab with no license', async () => {
const baseState: DeepPartial<GlobalState> = {
entities: {
general: {
license: {
SkuShortName: '',
},
},
},
};
renderWithContext(<ChannelSettingsModal {...baseProps}/>, baseState as GlobalState);
expect(screen.queryByTestId('configuration-tab-button')).not.toBeInTheDocument();
});
it('should not show configuration tab with professional license', async () => {
const baseState: DeepPartial<GlobalState> = {
entities: {
general: {
license: {
SkuShortName: 'professional',
},
},
},
};
renderWithContext(<ChannelSettingsModal {...baseProps}/>, baseState as GlobalState);
expect(screen.queryByTestId('configuration-tab-button')).not.toBeInTheDocument();
});
it('should not show configuration tab with enterprise license', async () => {
const baseState: DeepPartial<GlobalState> = {
entities: {
general: {
license: {
SkuShortName: 'enterprise',
},
},
},
};
renderWithContext(<ChannelSettingsModal {...baseProps}/>, baseState as GlobalState);
expect(screen.queryByTestId('configuration-tab-button')).not.toBeInTheDocument();
});
it('should show configuration tab when premium license', async () => {
const baseState: DeepPartial<GlobalState> = {
entities: {
general: {
license: {
SkuShortName: 'premium',
},
},
},
};
renderWithContext(<ChannelSettingsModal {...baseProps}/>, baseState as GlobalState);
expect(screen.getByTestId('configuration-tab-button')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,240 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {
useState,
useRef,
} from 'react';
import {useIntl} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import {GenericModal} from '@mattermost/components';
import type {Channel} from '@mattermost/types/channels';
import Permissions from 'mattermost-redux/constants/permissions';
import {selectChannelBannerEnabled} from 'mattermost-redux/selectors/entities/channel_banner';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {
setShowPreviewOnChannelSettingsHeaderModal,
setShowPreviewOnChannelSettingsPurposeModal,
} from 'actions/views/textbox';
import {focusElement} from 'utils/a11y_utils';
import Constants from 'utils/constants';
import type {GlobalState} from 'types/store';
import ChannelSettingsArchiveTab from './channel_settings_archive_tab';
import ChannelSettingsConfigurationTab from './channel_settings_configuration_tab';
import ChannelSettingsInfoTab from './channel_settings_info_tab';
import './channel_settings_modal.scss';
// Lazy-loaded components
const SettingsSidebar = React.lazy(() => import('components/settings_sidebar'));
type ChannelSettingsModalProps = {
channelId: string;
onExited: () => void;
isOpen: boolean;
focusOriginElement?: string;
};
enum ChannelSettingsTabs {
INFO = 'info',
CONFIGURATION = 'configuration',
ARCHIVE = 'archive',
}
const SHOW_PANEL_ERROR_STATE_TAB_SWITCH_TIMEOUT = 3000;
function ChannelSettingsModal({channelId, isOpen, onExited, focusOriginElement}: ChannelSettingsModalProps) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const channel = useSelector((state: GlobalState) => getChannel(state, channelId)) as Channel;
const shouldShowConfigurationTab = useSelector(selectChannelBannerEnabled);
const canArchivePrivateChannels = useSelector((state: GlobalState) =>
haveIChannelPermission(state, channel.team_id, channel.id, Permissions.DELETE_PRIVATE_CHANNEL),
);
const canArchivePublicChannels = useSelector((state: GlobalState) =>
haveIChannelPermission(state, channel.team_id, channel.id, Permissions.DELETE_PUBLIC_CHANNEL),
);
const [show, setShow] = useState(isOpen);
// Active tab
const [activeTab, setActiveTab] = useState<ChannelSettingsTabs>(ChannelSettingsTabs.INFO);
// State for showing error in the save changes panel when trying to switch tabs with unsaved changes
const [showTabSwitchError, setShowTabSwitchError] = useState(false);
// State to track if there are unsaved changes
const [areThereUnsavedChanges, setAreThereUnsavedChanges] = useState(false);
// Refs
const modalBodyRef = useRef<HTMLDivElement>(null);
// Called to set the active tab, prompting save changes panel if there are unsaved changes
const updateTab = (newTab: string) => {
/**
* If there are unsaved changes, show an error in the save changes panel
* and reset it after a timeout to indicate the user needs to save or discard changes
* before switching tabs.
*/
if (areThereUnsavedChanges) {
setShowTabSwitchError(true);
setTimeout(() => {
setShowTabSwitchError(false);
}, SHOW_PANEL_ERROR_STATE_TAB_SWITCH_TIMEOUT);
return;
}
const tab = newTab as ChannelSettingsTabs;
setActiveTab(tab);
if (modalBodyRef.current) {
modalBodyRef.current.scrollTop = 0;
}
};
const handleHide = () => {
handleHideConfirm();
};
const handleHideConfirm = () => {
// Reset preview states to false when closing the modal
dispatch(setShowPreviewOnChannelSettingsHeaderModal(false));
dispatch(setShowPreviewOnChannelSettingsPurposeModal(false));
setShow(false);
};
// Called after the fade-out completes
const handleExited = () => {
// Clear anything if needed
setActiveTab(ChannelSettingsTabs.INFO);
if (focusOriginElement) {
focusElement(focusOriginElement, true);
}
onExited();
};
// Renders content based on active tab
const renderTabContent = () => {
switch (activeTab) {
case ChannelSettingsTabs.INFO:
return renderInfoTab();
case ChannelSettingsTabs.CONFIGURATION:
return renderConfigurationTab();
case ChannelSettingsTabs.ARCHIVE:
return renderArchiveTab();
default:
return renderInfoTab();
}
};
const renderInfoTab = () => {
return (
<ChannelSettingsInfoTab
channel={channel}
setAreThereUnsavedChanges={setAreThereUnsavedChanges}
showTabSwitchError={showTabSwitchError}
/>
);
};
const renderConfigurationTab = () => {
return (
<ChannelSettingsConfigurationTab
channel={channel}
setAreThereUnsavedChanges={setAreThereUnsavedChanges}
showTabSwitchError={showTabSwitchError}
/>
);
};
const renderArchiveTab = () => {
return (
<ChannelSettingsArchiveTab
channel={channel}
onHide={handleHideConfirm}
/>
);
};
// Define tabs for the settings sidebar
const tabs = [
{
name: ChannelSettingsTabs.INFO,
uiName: formatMessage({id: 'channel_settings.tab.info', defaultMessage: 'Info'}),
icon: 'icon icon-information-outline',
iconTitle: formatMessage({id: 'generic_icons.info', defaultMessage: 'Info Icon'}),
},
{
name: ChannelSettingsTabs.CONFIGURATION,
uiName: formatMessage({id: 'channel_settings.tab.configuration', defaultMessage: 'Configuration'}),
icon: 'icon icon-cog-outline',
iconTitle: formatMessage({id: 'generic_icons.settings', defaultMessage: 'Settings Icon'}),
display: shouldShowConfigurationTab,
},
{
name: ChannelSettingsTabs.ARCHIVE,
uiName: formatMessage({id: 'channel_settings.tab.archive', defaultMessage: 'Archive Channel'}),
icon: 'icon icon-archive-outline',
iconTitle: formatMessage({id: 'generic_icons.archive', defaultMessage: 'Archive Icon'}),
newGroup: true,
display: channel.name !== Constants.DEFAULT_CHANNEL && // archive is not available for the default channel
((channel.type === Constants.PRIVATE_CHANNEL && canArchivePrivateChannels) ||
(channel.type === Constants.OPEN_CHANNEL && canArchivePublicChannels)),
},
];
// Renders the body: left sidebar for tabs, the content on the right
const renderModalBody = () => {
return (
<div
ref={modalBodyRef}
className='settings-table'
>
<div className='settings-links'>
<React.Suspense fallback={null}>
<SettingsSidebar
tabs={tabs}
activeTab={activeTab}
updateTab={updateTab}
/>
</React.Suspense>
</div>
<div className='settings-content minimize-settings'>
{renderTabContent()}
</div>
</div>
);
};
const modalTitle = formatMessage({id: 'channel_settings.modal.title', defaultMessage: 'Channel Settings'});
return (
<GenericModal
id='channelSettingsModal'
ariaLabel={modalTitle}
className='ChannelSettingsModal settings-modal'
show={show}
onHide={handleHide}
onExited={handleExited}
compassDesign={true}
modalHeaderText={modalTitle}
bodyPadding={false}
modalLocation={'top'}
>
<div className='ChannelSettingsModal__bodyWrapper'>
{renderModalBody()}
</div>
</GenericModal>
);
}
export default ChannelSettingsModal;

View file

@ -20,7 +20,7 @@ const ChannelHeader = makeAsyncComponent('ChannelHeader', lazy(() => import('com
const FileUploadOverlay = makeAsyncComponent('FileUploadOverlay', lazy(() => import('components/file_upload_overlay')));
const ChannelBookmarks = makeAsyncComponent('ChannelBookmarks', lazy(() => import('components/channel_bookmarks')));
const AdvancedCreatePost = makeAsyncComponent('AdvancedCreatePost', lazy(() => import('components/advanced_create_post')));
const ChannelBanner = makeAsyncComponent('ChannelBanner', lazy(() => import('components/channel_banner')));
const ChannelBanner = makeAsyncComponent('ChannelBanner', lazy(() => import('components/channel_banner/channel_banner')));
export type Props = PropsFromRedux & RouteComponentProps<{
postid?: string;

View file

@ -1,12 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import {focusElement} from 'utils/a11y_utils';
import './confirm_modal.scss';
type Props = {
@ -81,6 +82,12 @@ type Props = {
* Set to hide the cancel button
*/
hideCancel?: boolean;
/*
* The element that triggered the modal
*/
focusOriginElement?: string;
};
type State = {
@ -123,6 +130,13 @@ export default class ConfirmModal extends React.Component<Props, State> {
this.props.onCancel?.(this.state.checked);
};
handleExited = () => {
this.props.onExited?.();
if (this.props.focusOriginElement) {
focusElement(this.props.focusOriginElement!, true);
}
};
render() {
let checkbox;
if (this.props.showCheckbox) {
@ -169,11 +183,12 @@ export default class ConfirmModal extends React.Component<Props, State> {
return (
<GenericModal
id={classNames('confirmModal', this.props.id)}
id={this.props.id || 'confirmModal'}
className={`ConfirmModal a11y__modal ${this.props.modalClass}`}
show={this.props.show}
onHide={this.handleCancel}
onExited={this.props.onExited}
onExited={this.handleExited}
ariaLabelledby='confirmModalLabel'
compassDesign={true}
modalHeaderText={this.props.title}
>
@ -191,11 +206,11 @@ export default class ConfirmModal extends React.Component<Props, State> {
{this.props.checkboxInFooter && checkbox}
{cancelButton}
<button
autoFocus={true}
type='button'
className={this.props.confirmButtonClass}
onClick={this.handleConfirm}
id='confirmModalButton'
autoFocus={true}
>
{this.props.confirmButtonText}
</button>

View file

@ -498,6 +498,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
title="Save Outgoing OAuth Connection"
>
<GenericModal
ariaLabelledby="confirmModalLabel"
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={true}
@ -516,7 +517,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
>
<Modal
animation={true}
aria-labelledby="genericModalLabel"
aria-labelledby="confirmModalLabel"
aria-modal="true"
autoFocus={true}
backdrop={true}
@ -1101,6 +1102,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
title="Save Outgoing OAuth Connection"
>
<GenericModal
ariaLabelledby="confirmModalLabel"
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={true}
@ -1119,7 +1121,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
>
<Modal
animation={true}
aria-labelledby="genericModalLabel"
aria-labelledby="confirmModalLabel"
aria-modal="true"
autoFocus={true}
backdrop={true}

View file

@ -487,6 +487,7 @@ exports[`components/integrations/AddOutgoingOAuthConnection should match snapsho
title="Save Outgoing OAuth Connection"
>
<GenericModal
ariaLabelledby="confirmModalLabel"
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={true}
@ -505,7 +506,7 @@ exports[`components/integrations/AddOutgoingOAuthConnection should match snapsho
>
<Modal
animation={true}
aria-labelledby="genericModalLabel"
aria-labelledby="confirmModalLabel"
aria-modal="true"
autoFocus={true}
backdrop={true}

View file

@ -549,6 +549,7 @@ https://myothersite.com/api/v2"
}
>
<GenericModal
ariaLabelledby="confirmModalLabel"
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={true}
@ -564,6 +565,7 @@ https://myothersite.com/api/v2"
/>
}
modalLocation="center"
onExited={[Function]}
onHide={[Function]}
show={false}
showCloseButton={true}
@ -571,7 +573,7 @@ https://myothersite.com/api/v2"
>
<Modal
animation={true}
aria-labelledby="genericModalLabel"
aria-labelledby="confirmModalLabel"
aria-modal="true"
autoFocus={true}
backdrop={true}
@ -593,6 +595,7 @@ https://myothersite.com/api/v2"
"remove": [Function],
}
}
onExited={[Function]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
@ -669,6 +672,7 @@ https://myothersite.com/api/v2"
title="Save Outgoing OAuth Connection"
>
<GenericModal
ariaLabelledby="confirmModalLabel"
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={true}
@ -687,7 +691,7 @@ https://myothersite.com/api/v2"
>
<Modal
animation={true}
aria-labelledby="genericModalLabel"
aria-labelledby="confirmModalLabel"
aria-modal="true"
autoFocus={true}
backdrop={true}
@ -1305,6 +1309,7 @@ https://myothersite.com/api/v2"
}
>
<GenericModal
ariaLabelledby="confirmModalLabel"
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={true}
@ -1320,6 +1325,7 @@ https://myothersite.com/api/v2"
/>
}
modalLocation="center"
onExited={[Function]}
onHide={[Function]}
show={false}
showCloseButton={true}
@ -1327,7 +1333,7 @@ https://myothersite.com/api/v2"
>
<Modal
animation={true}
aria-labelledby="genericModalLabel"
aria-labelledby="confirmModalLabel"
aria-modal="true"
autoFocus={true}
backdrop={true}
@ -1349,6 +1355,7 @@ https://myothersite.com/api/v2"
"remove": [Function],
}
}
onExited={[Function]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
@ -1425,6 +1432,7 @@ https://myothersite.com/api/v2"
title="Save Outgoing OAuth Connection"
>
<GenericModal
ariaLabelledby="confirmModalLabel"
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={true}
@ -1443,7 +1451,7 @@ https://myothersite.com/api/v2"
>
<Modal
animation={true}
aria-labelledby="genericModalLabel"
aria-labelledby="confirmModalLabel"
aria-modal="true"
autoFocus={true}
backdrop={true}
@ -2060,6 +2068,7 @@ https://myothersite.com/api/v2"
}
>
<GenericModal
ariaLabelledby="confirmModalLabel"
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={true}
@ -2075,6 +2084,7 @@ https://myothersite.com/api/v2"
/>
}
modalLocation="center"
onExited={[Function]}
onHide={[Function]}
show={false}
showCloseButton={true}
@ -2082,7 +2092,7 @@ https://myothersite.com/api/v2"
>
<Modal
animation={true}
aria-labelledby="genericModalLabel"
aria-labelledby="confirmModalLabel"
aria-modal="true"
autoFocus={true}
backdrop={true}
@ -2104,6 +2114,7 @@ https://myothersite.com/api/v2"
"remove": [Function],
}
}
onExited={[Function]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
@ -2180,6 +2191,7 @@ https://myothersite.com/api/v2"
title="Save Outgoing OAuth Connection"
>
<GenericModal
ariaLabelledby="confirmModalLabel"
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={true}
@ -2198,7 +2210,7 @@ https://myothersite.com/api/v2"
>
<Modal
animation={true}
aria-labelledby="genericModalLabel"
aria-labelledby="confirmModalLabel"
aria-modal="true"
autoFocus={true}
backdrop={true}

View file

@ -1,150 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/RenameChannelModal should match snapshot 1`] = `
<Modal
animation={true}
aria-labelledby="renameChannelModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal"
dialogComponentClass={[Function]}
enforceFocus={true}
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onEntering={[Function]}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="renameChannelModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="Rename Channel"
id="rename_channel.title"
/>
</ModalTitle>
</ModalHeader>
<form
role="form"
>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<div
className="form-group"
>
<label
className="control-label"
htmlFor="display_name"
>
<MemoizedFormattedMessage
defaultMessage="Display Name"
id="rename_channel.displayName"
/>
</label>
<input
aria-label="display name"
className="form-control"
id="display_name"
maxLength={64}
onChange={[Function]}
placeholder="Enter display name"
type="text"
value="Fake Channel"
/>
</div>
<div
className="form-group"
>
<label
className="control-label"
htmlFor="channel_name"
>
URL
</label>
<div
className="input-group input-group--limit"
>
<WithTooltip
title="fake-channel/channels"
>
<span
className="input-group-addon"
>
fake-channel/channels/
</span>
</WithTooltip>
<input
aria-label="rename channel"
className="form-control"
id="channel_name"
maxLength={64}
onChange={[Function]}
readOnly={false}
type="text"
value="fake-channel"
/>
</div>
<p
className="input__help"
>
<MemoizedFormattedMessage
defaultMessage="You can use lowercase letters, numbers, dashes, and underscores."
id="change_url.helpText"
/>
</p>
</div>
</ModalBody>
<ModalFooter
bsClass="modal-footer"
componentClass="div"
>
<button
className="btn btn-tertiary"
onClick={[Function]}
type="button"
>
<MemoizedFormattedMessage
defaultMessage="Cancel"
id="rename_channel.cancel"
/>
</button>
<button
className="btn btn-primary"
id="save-button"
onClick={[Function]}
type="submit"
>
<MemoizedFormattedMessage
defaultMessage="Save"
id="rename_channel.save"
/>
</button>
</ModalFooter>
</form>
</Modal>
`;

View file

@ -1,40 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import type {GlobalState} from '@mattermost/types/store';
import {patchChannel} from 'mattermost-redux/actions/channels';
import {createSelector} from 'mattermost-redux/selectors/create_selector';
import {getTeam} from 'mattermost-redux/selectors/entities/teams';
import {getSiteURL} from 'utils/url';
import RenameChannelModal from './rename_channel_modal';
const mapStateToPropsRenameChannel = createSelector(
'mapStateToPropsRenameChannel',
(state: GlobalState) => {
const currentTeamId = state.entities.teams.currentTeamId;
const team = getTeam(state, currentTeamId);
const currentTeamUrl = `${getSiteURL()}/${team ? team.name : ''}`;
return {
currentTeamUrl,
team,
};
},
(teamInfo) => ({...teamInfo}),
);
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
patchChannel,
}, dispatch),
};
}
export default connect(mapStateToPropsRenameChannel, mapDispatchToProps)(RenameChannelModal);

View file

@ -1,210 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import type {Team} from '@mattermost/types/teams';
import {RequestStatus} from 'mattermost-redux/constants';
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
import {TestHelper} from 'utils/test_helper';
import RenameChannelModal from './rename_channel_modal';
import type {RenameChannelModal as RenameChannelModalClass} from './rename_channel_modal';
describe('components/RenameChannelModal', () => {
const channel: Channel = TestHelper.getChannelMock({
id: 'fake-id',
name: 'fake-channel',
display_name: 'Fake Channel',
});
const team: Team = TestHelper.getTeamMock({
name: 'Fake Team',
display_name: 'fake-team',
});
const baseProps = {
show: true,
onExited: jest.fn(),
channel: {...channel},
requestStatus: RequestStatus.NOT_STARTED,
team: {...team},
currentTeamUrl: 'fake-channel',
actions: {patchChannel: jest.fn().mockResolvedValue({data: true})},
};
test('should match snapshot', () => {
const wrapper = shallowWithIntl(
<RenameChannelModal {...baseProps}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should submit form', () => {
const {actions: {patchChannel}} = baseProps;
const props = {...baseProps, requestStatus: RequestStatus.STARTED};
const wrapper = shallowWithIntl(
<RenameChannelModal {...props}/>,
);
wrapper.find('#display_name').simulate(
'change', {preventDefault: jest.fn(), target: {value: 'valid name'}},
);
wrapper.find('#save-button').simulate('click');
expect(patchChannel).toHaveBeenCalled();
});
describe('should validate channel url (name)', () => {
const testCases: Array<[{name: string; value: string}, boolean]> = [
[{name: 'must be one or more characters', value: ''}, false],
[{name: 'must start with a letter or number', value: '_channel'}, false],
[{name: 'must end with a letter or number', value: 'channel_'}, false],
[{name: 'can contain two underscores in a row', value: 'channel__two'}, true],
[{name: 'can not resemble direct message channel url', value: 'uzsfmtmniifsjgesce4u7yznyh__uzsfmtmniifsjgesce4u7yznyh'}, false],
[{name: 'valid channel url', value: 'a_valid_channel'}, true],
];
testCases.forEach(([testCaseProps, patchShouldHaveBeenCalled]) => {
it(testCaseProps.name, () => {
const {actions: {patchChannel}} = baseProps;
const wrapper = shallowWithIntl(
<RenameChannelModal {...baseProps}/>,
);
wrapper.setState({channelName: testCaseProps.value});
wrapper.find('#save-button').simulate('click');
if (patchShouldHaveBeenCalled) {
expect(patchChannel).toHaveBeenCalled();
} else {
expect(patchChannel).not.toHaveBeenCalled();
}
});
});
});
test('should not call patchChannel as channel.name.length > Constants.MAX_CHANNELNAME_LENGTH (64)', () => {
const {actions: {patchChannel}} = baseProps;
const wrapper = shallowWithIntl(
<RenameChannelModal {...baseProps}/>,
);
wrapper.find('#display_name').simulate(
'change', {preventDefault: jest.fn(), target: {value: 'string-above-sixtyfour-characters-to-test-the-channel-maxlength-limit-properly-in-the-component'}},
);
wrapper.find('#save-button').simulate('click');
expect(patchChannel).not.toHaveBeenCalled();
});
test('should change state when display_name is edited', () => {
const wrapper = shallowWithIntl(
<RenameChannelModal {...baseProps}/>,
);
wrapper.find('#display_name').simulate(
'change', {preventDefault: jest.fn(), target: {value: 'New Fake Channel'}},
);
expect(wrapper.state('displayName')).toBe('New Fake Channel');
});
test('should call setError function', () => {
const wrapper = shallowWithIntl(
<RenameChannelModal {...baseProps}/>,
);
const instance = wrapper.instance() as RenameChannelModalClass;
instance.setError({message: 'This is an error message'});
expect(wrapper.state('serverError')).toBe('This is an error message');
});
test('should call unsetError function', () => {
const props = {...baseProps, serverError: {message: 'This is an error message'}};
const wrapper = shallowWithIntl(
<RenameChannelModal {...props}/>,
);
wrapper.setState({serverError: props.serverError.message});
expect(wrapper.state('serverError')).toBe('This is an error message');
wrapper.find('#save-button').simulate('click');
expect(wrapper.state('serverError')).toBe('');
});
test('should call handleSubmit function', async () => {
const patchChannel = jest.fn().
mockResolvedValueOnce({error: true}).
mockResolvedValue({data: true});
const wrapper = shallowWithIntl(
<RenameChannelModal
{...baseProps}
actions={{patchChannel}}
/>,
);
wrapper.setState({displayName: 'Changed Name', channelName: 'changed-name'});
const instance = wrapper.instance() as RenameChannelModalClass;
instance.onSaveSuccess = jest.fn();
instance.setError = jest.fn();
await instance.handleSubmit();
expect(patchChannel).toHaveBeenCalledTimes(1);
expect(wrapper.state('displayName')).toBe('Changed Name');
expect(wrapper.state('channelName')).toBe('changed-name');
expect(instance.onSaveSuccess).not.toBeCalled();
expect(instance.setError).toBeCalledTimes(1);
expect(instance.setError).toBeCalledWith(true);
await instance.handleSubmit();
expect(patchChannel).toHaveBeenCalledTimes(2);
expect(wrapper.state('displayName')).toBe('Changed Name');
expect(wrapper.state('channelName')).toBe('changed-name');
expect(instance.onSaveSuccess).toBeCalledTimes(1);
expect(instance.setError).toBeCalledTimes(1);
});
test('should call handleCancel', () => {
const wrapper = shallowWithIntl(
<RenameChannelModal {...baseProps}/>,
);
const instance = wrapper.instance() as RenameChannelModalClass;
instance.handleCancel();
expect(wrapper.state('show')).toBeFalsy();
});
test('should call handleHide function', () => {
const wrapper = shallowWithIntl(
<RenameChannelModal {...baseProps}/>,
);
const instance = wrapper.instance() as RenameChannelModalClass;
instance.handleHide();
expect(wrapper.state('show')).toBeFalsy();
});
test('should call onNameChange function', () => {
const changedName = {target: {value: 'changed-name'}};
const wrapper = shallowWithIntl(
<RenameChannelModal {...baseProps}/>,
);
const instance = wrapper.instance() as RenameChannelModalClass;
instance.onNameChange(changedName);
expect(wrapper.state('channelName')).toBe('changed-name');
});
});

View file

@ -1,367 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {ChangeEvent, MouseEvent} from 'react';
import {Modal} from 'react-bootstrap';
import {defineMessages, FormattedMessage, injectIntl} from 'react-intl';
import type {IntlShape} from 'react-intl';
import type {Channel} from '@mattermost/types/channels';
import type {ServerError} from '@mattermost/types/errors';
import type {Team} from '@mattermost/types/teams';
import type {ActionResult} from 'mattermost-redux/types/actions';
import WithTooltip from 'components/with_tooltip';
import {getHistory} from 'utils/browser_history';
import Constants from 'utils/constants';
import {getShortenedURL, validateChannelUrl} from 'utils/url';
import * as Utils from 'utils/utils';
const holders = defineMessages({
maxLength: {
id: 'rename_channel.maxLength',
defaultMessage: 'This field must be less than {maxLength, number} characters',
},
url: {
id: 'rename_channel.url',
defaultMessage: 'URL',
},
defaultError: {
id: 'rename_channel.defaultError',
defaultMessage: ' - Cannot be changed for the default channel',
},
});
type Props = {
/**
* react-intl helper object
*/
intl: IntlShape;
/**
* Function that is called when modal is hidden
*/
onExited: () => void;
/**
* Object with info about current channel
*/
channel: Channel;
/**
* Object with info about current team
*/
team?: Team;
/**
* String with the current team URL
*/
currentTeamUrl: string;
/*
* Object with redux action creators
*/
actions: {
/*
* Action creator to patch current channel
*/
patchChannel: (channelId: string, patch: Channel) => Promise<ActionResult>;
};
}
type State = {
displayName: string;
channelName: string;
serverError?: string;
urlErrors: React.ReactNode[];
displayNameError: React.ReactNode;
invalid: boolean;
show: boolean;
};
export class RenameChannelModal extends React.PureComponent<Props, State> {
private textbox?: HTMLInputElement;
constructor(props: Props) {
super(props);
this.state = {
displayName: props.channel.display_name,
channelName: props.channel.name,
serverError: '',
urlErrors: [],
displayNameError: '',
invalid: false,
show: true,
};
}
setError = (err: ServerError) => {
this.setState({serverError: err.message});
};
unsetError = () => {
this.setState({serverError: ''});
};
handleEntering = () => {
if (this.textbox) {
Utils.placeCaretAtEnd(this.textbox);
}
};
handleHide = (e?: MouseEvent) => {
if (e) {
e.preventDefault();
}
this.setState({
serverError: '',
urlErrors: [],
displayNameError: '',
invalid: false,
show: false,
});
};
handleSubmit = async (e?: MouseEvent<HTMLButtonElement>): Promise<void> => {
if (e) {
e.preventDefault();
}
const channel = Object.assign({}, this.props.channel);
const oldName = channel.name;
const oldDisplayName = channel.display_name;
const state = {...this.state, serverError: ''};
const {formatMessage} = this.props.intl;
const {actions: {patchChannel}} = this.props;
channel.display_name = this.state.displayName.trim();
if (!channel.display_name || channel.display_name.length < Constants.MIN_CHANNELNAME_LENGTH) {
state.displayNameError = (
<FormattedMessage
id='rename_channel.minLength'
defaultMessage='Display name must have at least {minLength, number} characters.'
values={{
minLength: Constants.MIN_CHANNELNAME_LENGTH,
}}
/>
);
state.invalid = true;
} else if (channel.display_name.length > Constants.MAX_CHANNELNAME_LENGTH) {
state.displayNameError = formatMessage(holders.maxLength, {maxLength: Constants.MAX_CHANNELNAME_LENGTH});
state.invalid = true;
} else {
state.displayNameError = '';
}
channel.name = this.state.channelName.trim();
const urlErrors = validateChannelUrl(channel.name);
if (urlErrors.length > 0) {
state.invalid = true;
}
state.urlErrors = urlErrors;
this.setState(state);
if (state.invalid) {
return;
}
if (oldName === channel.name && oldDisplayName === channel.display_name) {
this.onSaveSuccess();
return;
}
const {data, error} = await patchChannel(channel.id, channel);
if (data) {
this.onSaveSuccess();
} else if (error) {
this.setError(error);
}
};
onSaveSuccess = () => {
this.handleHide();
this.unsetError();
if (this.props.team) {
getHistory().push('/' + this.props.team.name + '/channels/' + this.state.channelName);
}
};
handleCancel = (e?: MouseEvent) => {
this.setState({
displayName: this.props.channel.display_name,
channelName: this.props.channel.name,
});
this.handleHide(e);
};
onNameChange = (e: ChangeEvent<HTMLInputElement> | {target: {value: string}}) => {
const name = e.target.value.trim().replace(/[^A-Za-z0-9-_]/g, '').toLowerCase();
this.setState({channelName: name});
};
onDisplayNameChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({displayName: e.target.value});
};
getTextbox = (node: HTMLInputElement) => {
this.textbox = node;
};
render(): JSX.Element {
let displayNameError = null;
if (this.state.displayNameError) {
displayNameError = <p className='input__help error'>{this.state.displayNameError}</p>;
}
let urlErrors = null;
let urlHelpText = null;
let urlInputClass = 'input-group input-group--limit';
if (this.state.urlErrors.length > 0) {
urlErrors = <p className='input__help error'>{this.state.urlErrors}</p>;
urlInputClass += ' has-error';
} else {
urlHelpText = (
<p className='input__help'>
<FormattedMessage
id='change_url.helpText'
defaultMessage='You can use lowercase letters, numbers, dashes, and underscores.'
/>
</p>
);
}
let serverError = null;
if (this.state.serverError) {
serverError = <div className='form-group has-error'><div className='as-bs-label control-label'>{this.state.serverError}</div></div>;
}
const {formatMessage} = this.props.intl;
let urlInputLabel = formatMessage(holders.url);
let readOnlyHandleInput = false;
if (this.props.channel.name === Constants.DEFAULT_CHANNEL) {
urlInputLabel += formatMessage(holders.defaultError);
readOnlyHandleInput = true;
}
const fullUrl = this.props.currentTeamUrl + '/channels';
const shortUrl = `${getShortenedURL(fullUrl, 35)}/`;
return (
<Modal
dialogClassName='a11y__modal'
show={this.state.show}
onHide={this.handleCancel}
onEntering={this.handleEntering}
onExited={this.props.onExited}
role='none'
aria-labelledby='renameChannelModalLabel'
>
<Modal.Header closeButton={true}>
<Modal.Title
componentClass='h1'
id='renameChannelModalLabel'
>
<FormattedMessage
id='rename_channel.title'
defaultMessage='Rename Channel'
/>
</Modal.Title>
</Modal.Header>
<form role='form'>
<Modal.Body>
<div className='form-group'>
<label
className='control-label'
htmlFor='display_name'
>
<FormattedMessage
id='rename_channel.displayName'
defaultMessage='Display Name'
/>
</label>
<input
onChange={this.onDisplayNameChange}
type='text'
ref={this.getTextbox}
id='display_name'
className='form-control'
placeholder={formatMessage({
id: 'rename_channel.displayNameHolder',
defaultMessage: 'Enter display name',
})}
value={this.state.displayName}
maxLength={Constants.MAX_CHANNELNAME_LENGTH}
aria-label={formatMessage({id: 'rename_channel.displayName', defaultMessage: 'Display Name'}).toLowerCase()}
/>
{displayNameError}
</div>
<div className='form-group'>
<label
className='control-label'
htmlFor='channel_name'
>
{urlInputLabel}
</label>
<div className={urlInputClass}>
<WithTooltip
title={fullUrl}
>
<span className='input-group-addon'>{shortUrl}</span>
</WithTooltip>
<input
onChange={this.onNameChange}
type='text'
className='form-control'
id='channel_name'
value={this.state.channelName}
maxLength={Constants.MAX_CHANNELNAME_LENGTH}
readOnly={readOnlyHandleInput}
aria-label={formatMessage({id: 'rename_channel.title', defaultMessage: 'Rename Channel'}).toLowerCase()}
/>
</div>
{urlHelpText}
{urlErrors}
</div>
{serverError}
</Modal.Body>
<Modal.Footer>
<button
type='button'
className='btn btn-tertiary'
onClick={this.handleCancel}
>
<FormattedMessage
id='rename_channel.cancel'
defaultMessage='Cancel'
/>
</button>
<button
onClick={this.handleSubmit}
type='submit'
id='save-button'
className='btn btn-primary'
>
<FormattedMessage
id='rename_channel.save'
defaultMessage='Save'
/>
</button>
</Modal.Footer>
</form>
</Modal>
);
}
}
export default injectIntl(RenameChannelModal);

View file

@ -8,13 +8,14 @@ import {FormattedMessage} from 'react-intl';
import Constants from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {a11yFocus} from 'utils/utils';
export type Tab = {
icon: string | {url: string};
iconTitle: string;
name: string;
uiName: string;
newGroup?: boolean;
display?: boolean; // Controls whether the tab is displayed, defaults to true
}
export type Props = {
@ -26,13 +27,49 @@ export type Props = {
};
export default class SettingsSidebar extends React.PureComponent<Props> {
buttonRefs: Array<RefObject<HTMLButtonElement>>;
totalTabs: Tab[];
buttonRefs: Map<string, RefObject<HTMLButtonElement>>;
constructor(props: Props) {
super(props);
this.totalTabs = [...this.props.tabs, ...this.props.pluginTabs || []];
this.buttonRefs = this.totalTabs.map(() => React.createRef());
// Initialize an empty Map for button refs
this.buttonRefs = new Map();
// Initialize refs for all tabs
this.initializeButtonRefs(props.tabs, props.pluginTabs);
}
// Initialize or update button refs for all tabs
private initializeButtonRefs(tabs: Tab[], pluginTabs?: Tab[]) {
// Clear existing refs if reinitializing
this.buttonRefs.clear();
// Create refs for all tabs, regardless of display status
tabs.forEach((tab) => {
this.buttonRefs.set(tab.name, React.createRef());
});
// Create refs for plugin tabs if they exist
if (pluginTabs?.length) {
pluginTabs.forEach((tab) => {
this.buttonRefs.set(tab.name, React.createRef());
});
}
}
// Update refs when props change
componentDidUpdate(prevProps: Props) {
// Check if tabs or pluginTabs have changed
if (prevProps.tabs !== this.props.tabs || prevProps.pluginTabs !== this.props.pluginTabs) {
this.initializeButtonRefs(this.props.tabs, this.props.pluginTabs);
}
}
// Get all visible tabs in the correct order
private getVisibleTabs(): Tab[] {
const visibleTabs = this.props.tabs.filter((tab) => tab.display !== false);
const visiblePluginTabs = this.props.pluginTabs?.filter((tab) => tab.display !== false) || [];
return [...visibleTabs, ...visiblePluginTabs];
}
public handleClick = (tab: Tab, e: React.MouseEvent) => {
@ -41,27 +78,57 @@ export default class SettingsSidebar extends React.PureComponent<Props> {
(e.target as Element).closest('.settings-modal')?.classList.add('display--content');
};
public handleKeyUp = (index: number, e: React.KeyboardEvent) => {
public handleKeyUp = (tab: Tab, e: React.KeyboardEvent) => {
// Only handle UP and DOWN arrow keys
if (!isKeyPressed(e, Constants.KeyCodes.UP) && !isKeyPressed(e, Constants.KeyCodes.DOWN)) {
return;
}
// Prevent default behavior
e.preventDefault();
// Get all visible tabs
const visibleTabs = this.getVisibleTabs();
// If no tabs are visible, do nothing
if (visibleTabs.length === 0) {
return;
}
// Find the current tab's position in the visible tabs
const currentIndex = visibleTabs.findIndex((t) => t.name === tab.name);
// If tab not found in visible tabs, do nothing
if (currentIndex === -1) {
return;
}
let nextIndex: number;
// Determine which tab to focus based on the key pressed
if (isKeyPressed(e, Constants.KeyCodes.UP)) {
if (index > 0) {
this.props.updateTab(this.totalTabs[index - 1].name);
a11yFocus(this.buttonRefs[index - 1].current);
} else {
this.props.updateTab(this.totalTabs[this.totalTabs.length - 1].name);
a11yFocus(this.buttonRefs[this.buttonRefs.length - 1].current);
}
} else if (isKeyPressed(e, Constants.KeyCodes.DOWN)) {
if (index < this.totalTabs.length - 1) {
this.props.updateTab(this.totalTabs[index + 1].name);
a11yFocus(this.buttonRefs[index + 1].current);
} else {
this.props.updateTab(this.totalTabs[0].name);
a11yFocus(this.buttonRefs[0].current);
}
// UP arrow key - move to previous tab or wrap to last
nextIndex = currentIndex > 0 ? currentIndex - 1 : visibleTabs.length - 1;
} else {
// DOWN arrow key - move to next tab or wrap to first
nextIndex = currentIndex < visibleTabs.length - 1 ? currentIndex + 1 : 0;
}
// Get the target tab
const targetTab = visibleTabs[nextIndex];
// Update the active tab
this.props.updateTab(targetTab.name);
// Focus the target tab button directly
const targetButton = this.buttonRefs.get(targetTab.name)?.current;
if (targetButton) {
// Use direct focus instead of a11yFocus to ensure Cypress tests can detect the focus change
targetButton.focus();
}
};
private renderTab(tab: Tab, index: number) {
private renderTab(tab: Tab) {
const key = `${tab.name}_li`;
const isActive = this.props.activeTab === tab.name;
@ -84,52 +151,63 @@ export default class SettingsSidebar extends React.PureComponent<Props> {
}
return (
<button
key={key}
ref={this.buttonRefs[index]}
id={`${tab.name}Button`}
className={classNames('cursor--pointer style--none nav-pills__tab', {active: isActive})}
onClick={this.handleClick.bind(null, tab)}
onKeyUp={this.handleKeyUp.bind(null, index)}
aria-label={tab.uiName.toLowerCase()}
role='tab'
aria-selected={isActive}
tabIndex={!isActive && !this.props.isMobileView ? -1 : 0}
aria-controls={`${tab.name}Settings`}
>
{icon}
{tab.uiName}
</button>
<React.Fragment key={key}>
{tab.newGroup && <hr/>}
<button
data-testid={`${tab.name}-tab-button`}
ref={this.buttonRefs.get(tab.name)}
id={`${tab.name}Button`}
className={classNames('cursor--pointer style--none nav-pills__tab', {active: isActive})}
onClick={this.handleClick.bind(null, tab)}
onKeyUp={this.handleKeyUp.bind(null, tab)}
aria-label={tab.uiName.toLowerCase()}
role='tab'
aria-selected={isActive}
tabIndex={!isActive && !this.props.isMobileView ? -1 : 0}
aria-controls={`${tab.name}Settings`}
>
{icon}
{tab.uiName}
</button>
</React.Fragment>
);
}
public render() {
const tabList = this.props.tabs.map((tab, index) => this.renderTab(tab, index));
// Filter regular tabs and plugin tabs separately for rendering
const visibleTabs = this.props.tabs.filter((tab) => tab.display !== false);
// Map regular tabs
const tabList = visibleTabs.map((tab) => this.renderTab(tab));
let pluginTabList: React.ReactNode;
if (this.props.pluginTabs?.length) {
pluginTabList = (
<>
<hr/>
<div
role='group'
aria-labelledby='userSettingsModal.pluginPreferences.header'
>
const visiblePluginTabs = this.props.pluginTabs.filter((tab) => tab.display !== false);
if (visiblePluginTabs.length) {
pluginTabList = (
<>
<hr/>
<div
key={'plugin preferences heading'}
role='heading'
className={'header'}
aria-level={3}
id='userSettingsModal_pluginPreferences_header'
role='group'
aria-labelledby='userSettingsModal.pluginPreferences.header'
>
<FormattedMessage
id={'userSettingsModal.pluginPreferences.header'}
defaultMessage={'PLUGIN PREFERENCES'}
/>
<div
key={'plugin preferences heading'}
role='heading'
className={'header'}
aria-level={3}
id='userSettingsModal_pluginPreferences_header'
>
<FormattedMessage
id={'userSettingsModal.pluginPreferences.header'}
defaultMessage={'PLUGIN PREFERENCES'}
/>
</div>
{visiblePluginTabs.map((tab) => this.renderTab(tab))}
</div>
{this.props.pluginTabs.map((tab, index) => this.renderTab(tab, index + this.props.tabs.length))}
</div>
</>
);
</>
);
}
}
return (

View file

@ -121,7 +121,6 @@ Object {
role="alert"
>
<i
aria-hidden="true"
class="icon error icon-alert-circle-outline"
/>
<span>

View file

@ -493,6 +493,7 @@ export default class SuggestionBox extends React.PureComponent {
});
if (e) {
e.preventDefault();
e.stopPropagation();
}
}
this.props.onKeyPress(ke);
@ -506,6 +507,11 @@ export default class SuggestionBox extends React.PureComponent {
}
}
}
if (e) {
e.stopPropagation();
}
return false;
};
@ -578,6 +584,7 @@ export default class SuggestionBox extends React.PureComponent {
this.selectNext();
e.preventDefault();
} else if ((Keyboard.isKeyPressed(e, KeyCodes.ENTER) && !ctrlOrMetaKeyPressed) || (this.props.completeOnTab && Keyboard.isKeyPressed(e, KeyCodes.TAB))) {
e.stopPropagation();
let matchedPretext = '';
for (let i = 0; i < this.state.terms.length; i++) {
if (this.state.terms[i] === this.state.selection) {

View file

@ -110,7 +110,7 @@ describe('components/TeamSettings', () => {
const newDomainText = screen.getByText(newDomain);
expect(newDomainText).toBeInTheDocument();
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});

View file

@ -70,7 +70,7 @@ describe('components/TeamSettings', () => {
userEvent.upload(input, file);
});
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
@ -87,7 +87,7 @@ describe('components/TeamSettings', () => {
userEvent.upload(input, file);
});
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
@ -107,7 +107,7 @@ describe('components/TeamSettings', () => {
act(() => {
userEvent.clear(input);
});
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
@ -124,7 +124,7 @@ describe('components/TeamSettings', () => {
await userEvent.clear(input);
await userEvent.type(input, 'a');
});
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
@ -139,7 +139,7 @@ describe('components/TeamSettings', () => {
const input = screen.getByTestId('teamNameInput');
userEvent.clear(input);
userEvent.type(input, 'new_team_name');
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
@ -155,7 +155,7 @@ describe('components/TeamSettings', () => {
await userEvent.clear(input);
await userEvent.type(input, 'new_team_description');
});
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});
@ -172,7 +172,7 @@ describe('components/TeamSettings', () => {
userEvent.type(nameInput, 'new_team_name');
userEvent.clear(descriptionInput);
userEvent.type(descriptionInput, 'new_team_description');
const saveButton = screen.getByTestId('mm-save-changes-panel__save-btn');
const saveButton = screen.getByTestId('SaveChangesPanel__save-btn');
await act(async () => {
userEvent.click(saveButton);
});

View file

@ -72,6 +72,7 @@ export type Props = {
openWhenEmpty?: boolean;
priorityProfiles?: UserProfile[];
hasLabels?: boolean;
hasError?: boolean;
isInEditMode?: boolean;
};
@ -201,7 +202,6 @@ export default class Textbox extends React.PureComponent<Props> {
if (!prevProps.preview && this.props.preview) {
this.preview.current?.focus();
}
this.updateSuggestions(prevProps);
}
@ -278,13 +278,17 @@ export default class Textbox extends React.PureComponent<Props> {
textboxClassName += ' textarea--has-labels';
}
if (this.props.hasError) {
textboxClassName += ' textarea--has-errors';
}
return (
<div
ref={this.wrapper}
className={classNames('textarea-wrapper', {'textarea-wrapper-preview': this.props.preview})}
>
<div
tabIndex={this.props.tabIndex || 0}
tabIndex={this.props.tabIndex}
ref={this.preview}
className={classNames('form-control custom-textarea textbox-preview-area', {'textarea--has-labels': this.props.hasLabels})}
onKeyPress={this.props.onKeyPress}

View file

@ -70,6 +70,7 @@ exports[`DesktopNotificationSettings should match snapshot, on max setting 1`] =
>
<label>
<input
class="a11y--active a11y--focused"
type="checkbox"
/>
Notify me about replies to threads I'm following
@ -293,6 +294,7 @@ exports[`DesktopNotificationSettings should not show desktop thread notification
>
<label>
<input
class="a11y--active a11y--focused"
type="checkbox"
/>
Use different settings for my mobile devices

View file

@ -0,0 +1,131 @@
@import 'utils/mixins';
.AdvancedTextbox {
position: relative;
&__wrapper {
position: relative;
display: flex;
flex-direction: column;
}
&__label {
position: absolute;
z-index: 1;
top: -6px; // Adjusted from -8px to -6px to center with border
left: 12px;
display: flex;
width: auto;
padding: 0 4px;
border: none;
background-color: var(--center-channel-bg);
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 10px;
line-height: 14px;
opacity: 0;
transform: translateY(8px);
transition-duration: 0.15s;
transition-property: opacity, transform, color;
transition-timing-function: ease-in-out;
white-space: nowrap;
&--active {
opacity: 1;
transform: translateY(0);
}
&--focused {
color: var(--button-bg); // Change color to match the border when focused
}
&--error {
color: var(--error-text); // Change color to red when there's an error
}
// Error state takes precedence over focus state
&--focused.AdvancedTextbox__label--error {
color: var(--error-text);
}
}
.textarea-wrapper {
position: relative;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
&:hover {
border-color: rgba(var(--center-channel-color-rgb), 0.48);
}
&:focus-within {
border-color: var(--button-bg);
box-shadow: inset 0 0 0 1px var(--button-bg);
}
// forcing this styles since textarea has ohter styles that affect the post input, so keeping it here isolated inside advanced textbox
.textarea--has-errors{
border-color: var(--error-text) !important;
&:focus {
border-color: var(--error-text) !important;
box-shadow: 0 0 0 1px var(--error-text) !important;
}
}
// Override default textarea styles to work with our border
.custom-textarea {
border: none;
box-shadow: none;
&:focus {
box-shadow: none;
}
}
}
&__error-wrapper {
display: flex;
}
&__error-message {
display: flex;
width: 90%;
align-items: center;
margin-top: 4px;
color: var(--error-text);
font-size: 12px;
i {
margin-right: 4px;
font-size: 14px;
}
}
&__character-count {
margin-top: 4px;
margin-left: auto;
font-size: 12px;
&.exceeds-limit {
color: var(--error-text);
}
&.below-minimum {
color: var(--error-text);
}
}
&__description {
display: flex;
align-items: center;
padding: 0;
margin-top: 4px;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-family: "Open Sans", sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
}
}

View file

@ -0,0 +1,321 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render, screen, fireEvent} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import type {ComponentProps} from 'react';
import AdvancedTextbox from './advanced_textbox';
// Mock dependencies
jest.mock('components/textbox', () => ({
__esModule: true,
default: jest.fn().mockImplementation((props) => (
<textarea
data-testid='mock-textbox'
id={props.id}
value={props.value}
onChange={props.onChange}
onKeyPress={props.onKeyPress}
placeholder={props.createMessage}
onFocus={props.onFocus}
onBlur={props.onBlur}
/>
)),
}));
jest.mock('components/advanced_text_editor/show_formatting/show_formatting', () => (
jest.fn().mockImplementation((props) => (
<button
data-testid='mock-show-format'
onClick={props.onClick}
className={props.active ? 'active' : ''}
>
{'Toggle Preview'}
</button>
))
));
describe('AdvancedTextbox', () => {
const defaultProps: ComponentProps<typeof AdvancedTextbox> = {
id: 'test-textbox',
value: 'Initial value',
channelId: 'channel1',
onChange: jest.fn(),
onKeyPress: jest.fn(),
createMessage: 'Enter text here',
maxLength: 1000,
preview: false,
togglePreview: jest.fn(),
useChannelMentions: false,
descriptionMessage: 'This is a description',
};
beforeEach(() => {
jest.clearAllMocks();
});
test('renders correctly with all props', () => {
render(<AdvancedTextbox {...defaultProps}/>);
// Check if textbox is rendered with correct value
const textbox = screen.getByTestId('mock-textbox');
expect(textbox).toBeInTheDocument();
expect(textbox).toHaveValue('Initial value');
// Check if description is rendered
expect(screen.getByText('This is a description')).toBeInTheDocument();
// Check if preview toggle button is rendered
expect(screen.getByTestId('mock-show-format')).toBeInTheDocument();
});
test('calls onChange when text is changed', async () => {
render(<AdvancedTextbox {...defaultProps}/>);
const textbox = screen.getByTestId('mock-textbox');
await userEvent.clear(textbox);
await userEvent.type(textbox, 'New text');
expect(defaultProps.onChange).toHaveBeenCalled();
});
test('calls onKeyPress when a key is pressed', async () => {
render(<AdvancedTextbox {...defaultProps}/>);
const textbox = screen.getByTestId('mock-textbox');
await userEvent.type(textbox, '{enter}');
expect(defaultProps.onKeyPress).toHaveBeenCalled();
});
test('calls togglePreview when preview button is clicked', async () => {
render(<AdvancedTextbox {...defaultProps}/>);
const previewButton = screen.getByTestId('mock-show-format');
await userEvent.click(previewButton);
expect(defaultProps.togglePreview).toHaveBeenCalledTimes(1);
});
test('renders with preview mode active when specified', () => {
render(<AdvancedTextbox {...{...defaultProps, preview: true}}/>);
const previewButton = screen.getByTestId('mock-show-format');
expect(previewButton).toHaveClass('active');
});
test('renders without description when not provided', () => {
render(<AdvancedTextbox {...{...defaultProps, descriptionMessage: undefined}}/>);
expect(screen.queryByTestId('mm-modal-generic-section-item__description')).not.toBeInTheDocument();
});
test('handles JSX element as descriptionMessage', () => {
const jsxDescription = <span data-testid='jsx-description'>{'JSX Description'}</span>;
render(<AdvancedTextbox {...{...defaultProps, descriptionMessage: jsxDescription}}/>);
expect(screen.getByTestId('jsx-description')).toBeInTheDocument();
expect(screen.getByText('JSX Description')).toBeInTheDocument();
});
test('displays character count when showCharacterCount is true and there is error', () => {
const props = {
...defaultProps,
maxLength: 10,
value: 'Short text',
showCharacterCount: true,
};
const {rerender} = render(<AdvancedTextbox {...props}/>);
rerender(<AdvancedTextbox {...{...props, value: 'This text is too long and exceeds the limit'}}/>);
expect(screen.getByText('43/10')).toBeInTheDocument();
});
test('shows error when text exceeds character limit', async () => {
const props = {
...defaultProps,
maxLength: 10,
value: 'Short text',
showCharacterCount: true,
};
const {rerender} = render(<AdvancedTextbox {...props}/>);
// Initially under the limit
expect(screen.queryByText(/exceeds the maximum character limit/)).not.toBeInTheDocument();
// Update with text that exceeds the limit
rerender(<AdvancedTextbox {...{...props, value: 'This text is too long and exceeds the limit'}}/>);
// Should show error message
expect(screen.getByText(/exceeds the maximum character limit/)).toBeInTheDocument();
expect(screen.getByText('This text is too long and exceeds the limit'.length + '/' + props.maxLength)).toBeInTheDocument();
});
test('shows error when text is below minimum character limit', async () => {
const props = {
...defaultProps,
maxLength: 100,
minLength: 10,
value: '',
showCharacterCount: true,
};
const {rerender} = render(<AdvancedTextbox {...props}/>);
// Empty text should not trigger min length error
expect(screen.queryByText(/must be at least/)).not.toBeInTheDocument();
// Update with text that is below the minimum limit
rerender(<AdvancedTextbox {...{...props, value: 'Too short'}}/>);
// Should show error message
expect(screen.getByText(/must be at least 10 characters/)).toBeInTheDocument();
expect(screen.getByText('9/10')).toBeInTheDocument();
});
test('allows custom error message for minimum length', async () => {
const props = {
...defaultProps,
maxLength: 100,
minLength: 10,
minLengthErrorMessage: 'Custom minimum length error',
value: 'Short',
showCharacterCount: true,
};
render(<AdvancedTextbox {...props}/>);
// Should show custom error message
expect(screen.getByText('Custom minimum length error')).toBeInTheDocument();
});
test('clears minimum length error when text meets requirements', async () => {
const props = {
...defaultProps,
maxLength: 100,
minLength: 5,
value: 'abc',
showCharacterCount: true,
};
const {rerender} = render(<AdvancedTextbox {...props}/>);
// Initially below the minimum
expect(screen.getByText(/must be at least 5 characters/)).toBeInTheDocument();
// Update with text that meets the minimum
rerender(<AdvancedTextbox {...{...props, value: 'abcdef'}}/>);
// Error should be cleared
expect(screen.queryByText(/must be at least 5 characters/)).not.toBeInTheDocument();
});
test('does not render preview toggle button when readOnly is true', () => {
render(<AdvancedTextbox {...{...defaultProps, readOnly: true}}/>);
// Preview toggle button should not be in the document
expect(screen.queryByTestId('mock-show-format')).not.toBeInTheDocument();
});
test('forces preview mode when readOnly is true regardless of preview prop', () => {
// Get reference to the mocked Textbox component
const TextboxMock = require('components/textbox').default;
// Clear previous calls to the mock
TextboxMock.mockClear();
// Render with readOnly true and preview false
render(<AdvancedTextbox {...{...defaultProps, readOnly: true, preview: false}}/>);
// Verify Textbox was called with preview=true
expect(TextboxMock).toHaveBeenCalledWith(
expect.objectContaining({
preview: true,
}),
expect.anything(),
);
});
// Tests for the floating label functionality
test('does not render label when name prop is not provided', () => {
render(<AdvancedTextbox {...defaultProps}/>);
// Label should not be in the document
expect(document.querySelector('.AdvancedTextbox__label')).not.toBeInTheDocument();
});
test('renders label when name prop is provided', () => {
render(<AdvancedTextbox {...{...defaultProps, name: 'Test Label'}}/>);
// Label should be in the document
const label = document.querySelector('.AdvancedTextbox__label');
expect(label).toBeInTheDocument();
expect(label).toHaveTextContent('Test Label');
});
test('applies active class to label when component has value', () => {
render(<AdvancedTextbox {...{...defaultProps, name: 'Test Label', value: 'Some value'}}/>);
// Label should have active class
const label = document.querySelector('.AdvancedTextbox__label');
expect(label).toHaveClass('AdvancedTextbox__label--active');
});
test('applies active class to label when component is focused', () => {
render(<AdvancedTextbox {...{...defaultProps, name: 'Test Label', value: ''}}/>);
// Initially label should not have active class
let label = document.querySelector('.AdvancedTextbox__label');
expect(label).not.toHaveClass('AdvancedTextbox__label--active');
// Focus the textbox
const textbox = screen.getByTestId('mock-textbox');
fireEvent.focus(textbox);
// Label should now have active class
label = document.querySelector('.AdvancedTextbox__label');
expect(label).toHaveClass('AdvancedTextbox__label--active');
});
test('removes active class from label when component loses focus and has no value', () => {
render(<AdvancedTextbox {...{...defaultProps, name: 'Test Label', value: ''}}/>);
// Focus the textbox
const textbox = screen.getByTestId('mock-textbox');
fireEvent.focus(textbox);
// Label should have active class
let label = document.querySelector('.AdvancedTextbox__label');
expect(label).toHaveClass('AdvancedTextbox__label--active');
// Blur the textbox
fireEvent.blur(textbox);
// Label should not have active class
label = document.querySelector('.AdvancedTextbox__label');
expect(label).not.toHaveClass('AdvancedTextbox__label--active');
});
test('keeps active class on label when component loses focus but has value', () => {
render(<AdvancedTextbox {...{...defaultProps, name: 'Test Label', value: 'Some value'}}/>);
// Focus the textbox
const textbox = screen.getByTestId('mock-textbox');
fireEvent.focus(textbox);
// Label should have active class
let label = document.querySelector('.AdvancedTextbox__label');
expect(label).toHaveClass('AdvancedTextbox__label--active');
// Blur the textbox
fireEvent.blur(textbox);
// Label should still have active class because there's a value
label = document.querySelector('.AdvancedTextbox__label');
expect(label).toHaveClass('AdvancedTextbox__label--active');
});
});

View file

@ -0,0 +1,184 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useState, useEffect} from 'react';
import {useIntl} from 'react-intl';
import ShowFormat from 'components/advanced_text_editor/show_formatting/show_formatting';
import Textbox from 'components/textbox';
import type {TextboxElement} from 'components/textbox';
import './advanced_textbox.scss';
type AdvancedTextboxProps = {
id: string;
value: string;
channelId: string;
onChange: (e: React.ChangeEvent<TextboxElement>) => void;
onKeyPress: (e: React.KeyboardEvent<TextboxElement>) => void;
createMessage: string;
maxLength: number;
minLength?: number;
minLengthErrorMessage?: string;
preview: boolean;
togglePreview: () => void;
useChannelMentions?: boolean;
descriptionMessage?: JSX.Element | string;
hasError?: boolean;
errorMessage?: string | JSX.Element;
onValidate?: (value: string) => { isValid: boolean; errorMessage?: string };
showCharacterCount?: boolean;
readOnly?: boolean;
name?: string; // Added name prop for floating label
};
const AdvancedTextbox = ({
id,
value,
channelId,
onChange,
onKeyPress,
createMessage,
maxLength,
minLength,
minLengthErrorMessage,
preview,
togglePreview,
useChannelMentions = false,
descriptionMessage,
hasError,
errorMessage,
onValidate,
showCharacterCount = false,
readOnly = false,
name,
}: AdvancedTextboxProps) => {
const {formatMessage} = useIntl();
const [internalError, setInternalError] = useState<string | JSX.Element | undefined>(errorMessage);
const [isFocused, setIsFocused] = useState(false);
// Derived values
const isTooLong = value.length > maxLength;
const isTooShort = minLength !== undefined && value.length > 0 && value.length < minLength;
// Update internal error when prop changes or when validation state changes
useEffect(() => {
if (errorMessage) {
setInternalError(errorMessage);
} else if (isTooLong) {
setInternalError(formatMessage(
{id: 'advanced_textbox.max_length_error', defaultMessage: 'Text exceeds the maximum character limit of {maxLength} characters.'},
{maxLength},
));
} else if (isTooShort) {
setInternalError(minLengthErrorMessage || formatMessage(
{id: 'advanced_textbox.min_length_error', defaultMessage: 'Text must be at least {minLength} characters.'},
{minLength},
));
} else {
setInternalError(undefined);
}
}, [errorMessage, isTooLong, isTooShort, maxLength, minLength, minLengthErrorMessage, formatMessage]);
// Handle focus events
const handleFocus = () => {
setIsFocused(true);
};
const handleBlur = () => {
setIsFocused(false);
};
// Handle validation on change
const handleChange = (e: React.ChangeEvent<TextboxElement>) => {
const newValue = e.target.value;
// Run validation if provided
if (onValidate) {
const validationResult = onValidate(newValue);
if (validationResult.isValid === false) {
setInternalError(validationResult.errorMessage);
} else {
setInternalError(undefined);
}
}
// Call original onChange
onChange(e);
};
let localPreview = preview;
if (readOnly) {
localPreview = true;
}
return (
<div className='AdvancedTextbox'>
<div className='AdvancedTextbox__wrapper'>
{name && (
<div className={`AdvancedTextbox__label ${(value || isFocused) ? 'AdvancedTextbox__label--active' : ''} ${isFocused ? 'AdvancedTextbox__label--focused' : ''} ${hasError || internalError ? 'AdvancedTextbox__label--error' : ''}`}>
{name}
</div>
)}
<Textbox
value={value}
onChange={handleChange}
onKeyPress={onKeyPress}
supportsCommands={false}
suggestionListPosition='bottom'
createMessage={createMessage}
channelId={channelId}
id={id}
characterLimit={maxLength}
preview={localPreview}
useChannelMentions={useChannelMentions}
hasError={hasError}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
{!readOnly && value.trim().length > 0 && (
<ShowFormat
onClick={togglePreview}
active={preview}
/>)
}
<div className='AdvancedTextbox__error-wrapper'>
{/* Error message display */}
{internalError && (
<div className='AdvancedTextbox__error-message'>
<i className='icon icon-alert-circle-outline'/>
<span>{internalError}</span>
</div>
)}
{/* Character count display */}
{showCharacterCount && (isTooLong || isTooShort || internalError) && (
<div
className={classNames('AdvancedTextbox__character-count', {
'exceeds-limit': isTooLong,
'below-minimum': isTooShort,
})}
>
{value.length}{'/'}
{isTooShort ? minLength : maxLength}
</div>
)}
</div>
{/* Error message display */}
{(descriptionMessage && !internalError) && (
<p
data-testid='AdvancedTextbox__description'
className='AdvancedTextbox__description'
>
{descriptionMessage}
</p>
)}
</div>
);
};
export default AdvancedTextbox;

View file

@ -1,27 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/widgets/inputs/Input should match snapshot 1`] = `
<div
className="Input_container"
>
<fieldset
className="Input_fieldset"
<div>
<div
class="Input_container"
>
<legend
className="Input_legend"
/>
<div
className="Input_wrapper"
<fieldset
class="Input_fieldset"
>
<input
aria-invalid={false}
className="Input form-control medium"
id="input_"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
<legend
class="Input_legend"
/>
</div>
</fieldset>
<div
class="Input_wrapper"
>
<input
aria-invalid="false"
class="Input form-control medium"
id="input_"
value=""
/>
</div>
</fieldset>
</div>
</div>
`;

View file

@ -1,28 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import {act, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import WithTooltip from 'components/with_tooltip';
import {renderWithContext} from 'tests/react_testing_utils';
import Input from './input';
// Mock the WithTooltip component to avoid ref issues
jest.mock('components/with_tooltip', () => ({
__esModule: true,
default: ({children}: {children: React.ReactNode}) => children,
}));
// Mock the CloseCircleIcon component to avoid ref issues
jest.mock('@mattermost/compass-icons/components', () => ({
CloseCircleIcon: () => <div data-testid='close-circle-icon'/>,
}));
describe('components/widgets/inputs/Input', () => {
test('should match snapshot', () => {
const wrapper = shallow(
const {container} = renderWithContext(
<Input/>,
);
expect(wrapper).toMatchSnapshot();
expect(container).toMatchSnapshot();
});
test('should render with clearable enabled', () => {
test('should render with clearable enabled', async () => {
const value = 'value';
const clearableTooltipText = 'tooltip text';
const onClear = jest.fn();
const wrapper = shallow(
renderWithContext(
<Input
value={value}
clearable={true}
@ -31,18 +43,232 @@ describe('components/widgets/inputs/Input', () => {
/>,
);
const clear = wrapper.find('.Input__clear');
expect(clear.length).toEqual(1);
expect(wrapper.find('CloseCircleIcon').length).toEqual(1);
// Find the input with the value
const inputElement = screen.getByDisplayValue(value);
expect(inputElement).toBeInTheDocument();
const tooltip = wrapper.find(WithTooltip);
expect(tooltip.length).toEqual(1);
// Find the clear button's div container
const iconElement = screen.getByTestId('close-circle-icon');
expect(iconElement).toBeInTheDocument();
const titleProp = tooltip.prop('title');
expect(titleProp).toEqual(clearableTooltipText);
clear.first().simulate('mousedown');
// Click directly on the icon element
await act(async () => {
userEvent.click(iconElement);
});
// Verify onClear was called
expect(onClear).toHaveBeenCalledTimes(1);
});
describe('minLength validation', () => {
test('should show error styling when input is empty with minLength set', () => {
renderWithContext(
<Input
value={''}
minLength={5}
/>,
);
// Check for the +X indicator
const indicator = screen.getByText('+5');
expect(indicator).toBeInTheDocument();
// Check for error styling
const fieldset = screen.getByRole('group');
expect(fieldset).toHaveClass('Input_fieldset___error');
});
test('should show error styling and message when input length < minLength', async () => {
renderWithContext(
<Input
value={'abc'}
minLength={5}
/>,
);
// Find the input
const inputElement = screen.getByDisplayValue('abc');
// Simulate change to trigger validation
await act(async () => {
// Clear the input first
userEvent.clear(inputElement);
// Then type the new value
userEvent.type(inputElement, 'abc');
});
// Check for the +X indicator
const indicator = screen.getByText('+2');
expect(indicator).toBeInTheDocument();
// Check for error styling
const fieldset = screen.getByRole('group');
expect(fieldset).toHaveClass('Input_fieldset___error');
// Check for error message
const errorMessage = await screen.findByText(/Must be at least 5 characters/i);
expect(errorMessage).toBeInTheDocument();
});
test('should not show error styling when input length >= minLength', async () => {
const onChange = jest.fn();
renderWithContext(
<Input
value={'abcde'}
minLength={5}
onChange={onChange}
/>,
);
// With exactly 5 characters and minLength of 5, there should be no error
// Check that the +X indicator is not present
expect(screen.queryByText(/\+\d+/)).not.toBeInTheDocument();
// Check that error message is not present
expect(screen.queryByText(/Must be at least 5 characters/i)).not.toBeInTheDocument();
});
});
describe('maxLength (limit) validation', () => {
test('should show error styling when input length > limit', async () => {
// Create a mock onChange function
const onChange = jest.fn();
// Render with a value that exceeds the limit
renderWithContext(
<Input
value={'abcdef'}
limit={5}
onChange={onChange}
/>,
);
// With 6 characters and limit of 5, there should be an error
// Check for the -X indicator
const indicator = screen.getByText('-1');
expect(indicator).toBeInTheDocument();
// Check for error styling
const fieldset = screen.getByRole('group');
expect(fieldset).toHaveClass('Input_fieldset___error');
});
test('should not show error styling when input length <= limit', async () => {
const onChange = jest.fn();
renderWithContext(
<Input
value={'abcde'}
limit={5}
onChange={onChange}
/>,
);
// With exactly 5 characters and limit of 5, there should be no error
// Check that the -X indicator is not present
expect(screen.queryByText(/-\d+/)).not.toBeInTheDocument();
// Check that error message is not present
expect(screen.queryByText(/Must be no more than 5 characters/i)).not.toBeInTheDocument();
});
});
describe('required field validation', () => {
test('should not show error on empty required input until blur', async () => {
renderWithContext(
<Input
value={''}
required={true}
/>,
);
// Find the input
const inputElement = screen.getByRole('textbox');
// Check that error message is not present before blur
expect(screen.queryByText(/This field is required/i)).not.toBeInTheDocument();
// Simulate blur to trigger validation
await act(async () => {
inputElement.focus();
inputElement.blur();
});
// Check for error message after blur
const errorMessage = await screen.findByText(/This field is required/i);
expect(errorMessage).toBeInTheDocument();
// Check for error styling
const fieldset = screen.getByRole('group');
expect(fieldset).toHaveClass('Input_fieldset___error');
});
test('should not show error on non-empty required input', async () => {
renderWithContext(
<Input
value={'abc'}
required={true}
/>,
);
// Find the input
const inputElement = screen.getByDisplayValue('abc');
// Simulate blur to trigger validation
await act(async () => {
inputElement.focus();
inputElement.blur();
});
// Check that error message is not present
expect(screen.queryByText(/This field is required/i)).not.toBeInTheDocument();
});
});
describe('interaction between validations', () => {
test('should prioritize required validation over minLength on blur for empty input', async () => {
renderWithContext(
<Input
value={''}
required={true}
minLength={5}
/>,
);
// Find the input
const inputElement = screen.getByRole('textbox');
// Simulate blur to trigger validation
await act(async () => {
inputElement.focus();
inputElement.blur();
});
// Check for required error message
const errorMessage = await screen.findByText(/This field is required/i);
expect(errorMessage).toBeInTheDocument();
// Check that minLength error message is not present
expect(screen.queryByText(/Must be at least 5 characters/i)).not.toBeInTheDocument();
});
test('should show both minLength indicator and limit indicator when applicable', () => {
renderWithContext(
<Input
value={'abc'}
minLength={5}
limit={10}
/>,
);
// Check for the +X indicator for minLength
const indicator = screen.getByText('+2');
expect(indicator).toBeInTheDocument();
});
});
});

View file

@ -35,6 +35,7 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
wrapperClassName?: string;
inputClassName?: string;
limit?: number;
minLength?: number;
useLegend?: boolean;
customMessage?: CustomMessageInputType;
inputSize?: SIZE;
@ -61,6 +62,7 @@ const Input = React.forwardRef((
wrapperClassName,
inputClassName,
limit,
minLength,
customMessage,
maxLength,
inputSize = SIZE.MEDIUM,
@ -99,6 +101,21 @@ const Input = React.forwardRef((
}
}, [customMessage]);
// Re-validate input when value changes (e.g. when a parent component sets a new value,not just when the user types)
useEffect(() => {
// Only run validation if we're not focused (to avoid validating during typing)
// and if there is currently an error displayed
if (!focused && customInputLabel?.type === 'error') {
// Clear error state when value changes
setCustomInputLabel(null);
// Re-run validation to check if the new value is valid
if (value !== undefined && value !== null && value !== '') {
validateInput();
}
}
}, [value]); // Only run when value changes
const handleOnFocus = (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFocused(true);
@ -117,6 +134,7 @@ const Input = React.forwardRef((
};
const handleOnChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
// Clear custom messages when user types
setCustomInputLabel(null);
if (onChange) {
@ -131,17 +149,37 @@ const Input = React.forwardRef((
};
const validateInput = () => {
if (!required || (value !== null && value !== '')) {
return;
// Only check for required field validation on blur
// Length validation is handled through derived values in the render function
if (required && (value === null || value === '')) {
const validationErrorMsg = formatMessage({id: 'widget.input.required', defaultMessage: 'This field is required'});
setCustomInputLabel({type: ItemStatus.ERROR, value: validationErrorMsg});
}
const validationErrorMsg = formatMessage({id: 'widget.input.required', defaultMessage: 'This field is required'});
setCustomInputLabel({type: ItemStatus.ERROR, value: validationErrorMsg});
};
const showLegend = Boolean(focused || value);
const error = customInputLabel?.type === ItemStatus.ERROR;
const warning = customInputLabel?.type === ItemStatus.WARNING;
const limitExceeded = limit && value && !Array.isArray(value) ? value.toString().length - limit : 0;
const minLengthNotMet = minLength && value !== undefined && !Array.isArray(value) ? minLength - value.toString().length : (minLength || 0);
// Show min length error even when the input is empty (to match existing behavior in tests)
const isMinLengthError = minLengthNotMet > 0;
const isMaxLengthError = limitExceeded > 0;
// Generate derived error messages
let derivedErrorMessage: React.ReactNode | null = null;
if (isMaxLengthError && !customInputLabel) {
derivedErrorMessage = formatMessage(
{id: 'widget.input.max_length', defaultMessage: 'Must be no more than {limit} characters'},
{limit},
);
} else if (isMinLengthError && !customInputLabel) {
derivedErrorMessage = formatMessage(
{id: 'widget.input.min_length', defaultMessage: 'Must be at least {minLength} characters'},
{minLength},
);
}
const clearButton = value && clearable ? (
<div
@ -207,7 +245,7 @@ const Input = React.forwardRef((
<div className={classNames('Input_container', containerClassName, {disabled})}>
<fieldset
className={classNames('Input_fieldset', className, {
Input_fieldset___error: error || hasError || limitExceeded > 0,
Input_fieldset___error: hasError || limitExceeded > 0 || isMinLengthError || customInputLabel?.type === 'error',
Input_fieldset___legend: showLegend,
})}
>
@ -225,28 +263,32 @@ const Input = React.forwardRef((
{'-'}{limitExceeded}
</span>
)}
{isMinLengthError && (
<span className='Input_limit-exceeded'>
{'+'}{minLengthNotMet}
</span>
)}
{inputSuffix}
{clearButton}
</div>
{addon}
</fieldset>
{customInputLabel && (
{/* Display custom or derived error messages */}
{(customInputLabel || derivedErrorMessage) && (
<div
className={`Input___customMessage Input___${customInputLabel?.type || 'error'}`}
id={errorId}
className={`Input___customMessage Input___${customInputLabel.type}`}
role={error || warning ? 'alert' : undefined}
>
{customInputLabel.type && (
<i
className={classNames(`icon ${customInputLabel.type}`, {
'icon-alert-outline': customInputLabel.type === ItemStatus.WARNING,
'icon-alert-circle-outline': customInputLabel.type === ItemStatus.ERROR,
'icon-information-outline': customInputLabel.type === ItemStatus.INFO,
'icon-check': customInputLabel.type === ItemStatus.SUCCESS,
})}
aria-hidden='true'
/>)}
<span>{customInputLabel.value}</span>
<i
className={classNames(`icon ${customInputLabel?.type || 'error'}`, {
'icon-alert-outline': (customInputLabel?.type || 'error') === ItemStatus.WARNING,
'icon-alert-circle-outline': (customInputLabel?.type || 'error') === ItemStatus.ERROR,
'icon-information-outline': (customInputLabel?.type || 'error') === ItemStatus.INFO,
'icon-check': (customInputLabel?.type || 'error') === ItemStatus.SUCCESS,
})}
/>
<span>{customInputLabel?.value || derivedErrorMessage}</span>
</div>
)}
</div>

View file

@ -1,4 +1,4 @@
.mm-save-changes-panel {
.SaveChangesPanel {
position: fixed;
z-index: 1000;
display: flex;
@ -56,6 +56,7 @@
}
svg {
margin-right: 10px;
color: rgba(var(--center-channel-color-rgb), 0.56);
}
@ -124,5 +125,10 @@
background-color: var(--button-color);
color: var(--denim-button-bg);
}
&:disabled {
background: var(--center-channel-bg);
opacity: 0.32;
}
}
}

View file

@ -11,31 +11,49 @@ import './save_changes_panel.scss';
export type SaveChangesPanelState = 'editing' | 'saved' | 'error' | undefined;
const CLOSE_TIMEOUT = 1200;
type Props = {
handleSubmit: () => void;
handleCancel: () => void;
handleClose: () => void;
tabChangeError?: boolean;
state: SaveChangesPanelState;
customErrorMessage?: string;
saveButtonText?: React.ReactNode;
cancelButtonText?: React.ReactNode;
}
function SaveChangesPanel({handleSubmit, handleCancel, handleClose, tabChangeError = false, state = 'editing'}: Props) {
const panelClassName = classNames('mm-save-changes-panel', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
const messageClassName = classNames('mm-save-changes-panel__message', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
const cancelButtonClassName = classNames('mm-save-changes-panel__cancel-btn', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
const saveButtonClassName = classNames('mm-save-changes-panel__save-btn', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
function SaveChangesPanel({
handleSubmit,
handleCancel,
handleClose,
tabChangeError = false,
state = 'editing',
customErrorMessage,
saveButtonText,
cancelButtonText,
}: Props) {
const panelClassName = classNames('SaveChangesPanel', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
const messageClassName = classNames('SaveChangesPanel__message', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
const cancelButtonClassName = classNames('SaveChangesPanel__cancel-btn', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
const saveButtonClassName = classNames('SaveChangesPanel__save-btn', {error: tabChangeError || state === 'error'}, {saved: state === 'saved'});
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (state === 'saved') {
timeoutId = setTimeout(() => {
handleClose();
}, 1200);
}, CLOSE_TIMEOUT);
}
return () => clearTimeout(timeoutId);
}, [handleClose, state]);
const generateMessage = () => {
if (customErrorMessage && (tabChangeError || state === 'error')) {
return customErrorMessage;
}
if (tabChangeError || state === 'editing') {
return (
<FormattedMessage
@ -65,7 +83,7 @@ function SaveChangesPanel({handleSubmit, handleCancel, handleClose, tabChangeErr
const generateControlButtons = () => {
if (state === 'saved') {
return (
<div className='mm-save-changes-panel__btn-ctr'>
<div className='SaveChangesPanel__btn-ctr'>
<button
id='panelCloseButton'
data-testid='panelCloseButton'
@ -81,33 +99,34 @@ function SaveChangesPanel({handleSubmit, handleCancel, handleClose, tabChangeErr
);
}
const saveButtonDisabled = tabChangeError || state === 'error';
return (
<div className='mm-save-changes-panel__btn-ctr'>
<div className='SaveChangesPanel__btn-ctr'>
<button
data-testid='mm-save-changes-panel__cancel-btn'
data-testid='SaveChangesPanel__cancel-btn'
className={cancelButtonClassName}
onClick={handleCancel}
>
<FormattedMessage
id='saveChangesPanel.cancel'
defaultMessage='Undo'
/>
{cancelButtonText || (
<FormattedMessage
id='saveChangesPanel.cancel'
defaultMessage='Undo'
/>
)}
</button>
<button
data-testid='mm-save-changes-panel__save-btn'
data-testid='SaveChangesPanel__save-btn'
className={saveButtonClassName}
onClick={handleSubmit}
disabled={saveButtonDisabled}
>
{state === 'error' ?
<FormattedMessage
id='saveChangesPanel.tryAgain'
defaultMessage='Try again'
/> :
{saveButtonText || (
<FormattedMessage
id='saveChangesPanel.save'
defaultMessage='Save'
/>
}
)}
</button>
</div>
);

View file

@ -2936,6 +2936,8 @@
"adminConsole.list.table.rowsCount.50": "50",
"adminConsole.list.table.rowsCount.show(rowsPerPage)": "rows per page",
"advanced_text_editor.remote_user_hour": "The time for {user} is {time}",
"advanced_textbox.max_length_error": "Text exceeds the maximum character limit of {maxLength} characters.",
"advanced_textbox.min_length_error": "Text must be at least {minLength} characters.",
"air_gapped_contact_sales_modal.body": "Please access the link below to contact sales.",
"air_gapped_contact_sales_modal.title": "Looks like you do not have access to the internet",
"air_gapped_modal.close": "Close",
@ -3225,7 +3227,6 @@
"center_panel.input.cannot_load_component": "Something went wrong while loading the component. Please wait a moment, or try reloading the app.",
"center_panel.reloadPage": "Reload",
"change_url.endWithLetter": "URLs must end with a lowercase letter or number.",
"change_url.helpText": "You can use lowercase letters, numbers, dashes, and underscores.",
"change_url.invalidDirectMessage": "User IDs are not allowed in channel URLs.",
"change_url.invalidUrl": "Invalid URL",
"change_url.longer": "URLs must have at least 1 character.",
@ -3234,6 +3235,11 @@
"change_url.startAndEndWithLetter": "URLs must start and end with a lowercase letter or number.",
"change_url.startWithLetter": "URLs must start with a lowercase letter or number.",
"channel_banner.aria_label": "Channel banner text",
"channel_banner.banner_color.label": "Banner color",
"channel_banner.banner_text.label": "Banner text",
"channel_banner.banner_text.placeholder": "Channel banner text",
"channel_banner.label.name": "Channel Banner",
"channel_banner.label.subtext": "When enabled, a customized banner will display at the top of the channel.",
"channel_bookmarks.addBookmark": "Add a bookmark",
"channel_bookmarks.addBookmarkLimitReached": "Cannot add more than {limit} bookmarks",
"channel_bookmarks.addLink": "Add a link",
@ -3262,11 +3268,11 @@
"channel_bookmarks.editBookmarkLabel": "Bookmark menu",
"channel_bookmarks.open": "Open",
"channel_groups": "{channel} Groups",
"channel_header.channel_settings": "Channel Settings",
"channel_header.channelFiles": "Channel files",
"channel_header.channelHasGuests": "Channel has guests",
"channel_header.channelMembers": "Members",
"channel_header.closeChannelInfo": "Close Info",
"channel_header.convert": "Convert to Private Channel",
"channel_header.delete": "Archive Channel",
"channel_header.directchannel": "{displayName} (you) Channel Menu",
"channel_header.directchannel.you": "{displayname} (you) ",
@ -3283,11 +3289,8 @@
"channel_header.otherchannel": "{displayName} Channel Menu",
"channel_header.pinnedPosts": "Pinned messages",
"channel_header.recentMentions": "Recent mentions",
"channel_header.rename": "Rename Channel",
"channel_header.search": "Search",
"channel_header.setConversationHeader": "Edit Header",
"channel_header.setHeader": "Edit Channel Header",
"channel_header.setPurpose": "Edit Channel Purpose",
"channel_header.settings": "Settings",
"channel_header.unarchive": "Unarchive Channel",
"channel_header.unmute": "Unmute Channel",
@ -3388,7 +3391,7 @@
"channel_modal.handleTooShort": "Channel URL must be 1 or more lowercase alphanumeric characters",
"channel_modal.modalTitle": "Create a new channel",
"channel_modal.name.label": "Channel name",
"channel_modal.name.longer": "Channel names must have at least 1 character.",
"channel_modal.name.longer": "Channel names must have at least 2 character.",
"channel_modal.name.placeholder": "Enter a name for your new channel",
"channel_modal.name.shorter": "Channel names must have maximum 64 characters.",
"channel_modal.purpose.info": "This will be displayed when browsing for channels.",
@ -3424,6 +3427,30 @@
"channel_notifications.resetToDefault": "Reset to default",
"channel_notifications.ThreadsReplyTitle": "Thread reply notifications",
"channel_select.placeholder": "--- Select a channel ---",
"channel_settings_modal.header.placeholder": "Enter a header for this channel",
"channel_settings_modal.name.placeholder": "Enter a name for your channel",
"channel_settings_modal.purpose.placeholder": "Enter a purpose for this channel (optional)",
"channel_settings.archive.button": "Archive this channel",
"channel_settings.archive.warning": "Archiving a channel removes it from the user interface, but doesn't permanently delete the channel. New messages can't be posted to archived channels.",
"channel_settings.error_banner_color_required": "Banner color is required",
"channel_settings.error_banner_text_required": "Banner text is required",
"channel_settings.error_display_name_required": "Channel name is required",
"channel_settings.error_purpose_length": "The text entered exceeds the character limit. The channel purpose is limited to {maxLength} characters.",
"channel_settings.header.label": "Channel Header",
"channel_settings.label.name": "Channel Name",
"channel_settings.modal.archiveTitle": "Archive Channel?",
"channel_settings.modal.confirmArchive": "Confirm",
"channel_settings.modal.title": "Channel Settings",
"channel_settings.purpose.description": "Describe how this channel should be used.",
"channel_settings.purpose.header": "This is the text that will appear in the header of the channel beside the channel name. You can use markdown to include links by typing [Link Title](http://example.com).",
"channel_settings.purpose.label": "Channel Purpose",
"channel_settings.save_changes_panel.banner_text.required_error": "Channel banner text cannot be empty when enabled",
"channel_settings.save_changes_panel.reset": "Reset",
"channel_settings.save_changes_panel.standard_error": "There are errors in the form above",
"channel_settings.tab.archive": "Archive Channel",
"channel_settings.tab.configuration": "Configuration",
"channel_settings.tab.info": "Info",
"channel_settings.unknown_error": "Something went wrong.",
"channel_switch_modal.deactivated": "Deactivated",
"channel_toggle_button.private": "Private",
"channel_toggle_button.public": "Public",
@ -3439,7 +3466,6 @@
"channelNotifications.mobileNotification.newMessages": "All new messages {optionalDefault}",
"channelNotifications.mobileNotification.nothing": "Nothing {optionalDefault}",
"channelSelectorModal.title": "Add Channels to <b>Channel Selection</b> List",
"channelSettings": "Channel Settings",
"channelView.archivedChannel": "You are viewing an <b>archived channel</b>. New messages cannot be posted.",
"channelView.archivedChannelWithDeactivatedUser": "You are viewing an archived channel with a <b>deactivated user</b>. New messages cannot be posted.",
"channelView.login.successfull": "Login Successful",
@ -4008,6 +4034,7 @@
"generic_icons.reload": "Reload Icon",
"generic_icons.reply": "Reply Icon",
"generic_icons.search": "Search Icon",
"generic_icons.settings": "Settings Icon",
"generic_icons.success": "Success Icon",
"generic_icons.upgradeBadge": "Upgrade badge",
"generic_icons.user_groups": "User Groups Icon",
@ -4922,15 +4949,6 @@
"removed_channel.someone": "Someone",
"rename_category_modal.rename": "Rename",
"rename_category_modal.renameCategory": "Rename Category",
"rename_channel.cancel": "Cancel",
"rename_channel.defaultError": " - Cannot be changed for the default channel",
"rename_channel.displayName": "Display Name",
"rename_channel.displayNameHolder": "Enter display name",
"rename_channel.maxLength": "This field must be less than {maxLength, number} characters",
"rename_channel.minLength": "Display name must have at least {minLength, number} characters.",
"rename_channel.save": "Save",
"rename_channel.title": "Rename Channel",
"rename_channel.url": "URL",
"restricted_indicator.tooltip.mesage": "During your trial you are able to use this feature.",
"restricted_indicator.tooltip.message.blocked": "This is a paid feature, available with a free {trialLength}-day trial",
"restricted_indicator.tooltip.title": "{minimumPlanRequiredForFeature} feature",
@ -4962,7 +4980,6 @@
"saveChangesPanel.message": "You have unsaved changes",
"saveChangesPanel.save": "Save",
"saveChangesPanel.saved": "Settings saved",
"saveChangesPanel.tryAgain": "Try again",
"schedule_post.custom_time_modal.cancel_button_text": "Cancel",
"schedule_post.custom_time_modal.confirm_button_text": "Schedule",
"schedule_post.custom_time_modal.dm_user_time": "{dmUserTime} for {dmUserName}",
@ -6090,6 +6107,8 @@
"webapp.mattermost.feature.unlimited_messages": "Unlimited Messages",
"webapp.mattermost.feature.upgrade_downgraded_workspace": "Revert the workspace to a paid plan",
"widget.input.clear": "Clear",
"widget.input.max_length": "Must be no more than {limit} characters",
"widget.input.min_length": "Must be at least {minLength} characters",
"widget.input.required": "This field is required",
"widget.passwordInput.createPassword": "Choose a Password",
"widget.passwordInput.password": "Password",

View file

@ -8,12 +8,20 @@ import {General} from 'mattermost-redux/constants';
import {getChannel, getChannelBanner} from 'mattermost-redux/selectors/entities/channels';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
export const selectShowChannelBanner = (state: GlobalState, channelId: string): boolean => {
export const selectChannelBannerEnabled = (state: GlobalState): boolean => {
const license = getLicense(state);
return license?.SkuShortName === General.SKUPremium;
};
export const selectShowChannelBanner = (state: GlobalState, channelId: string): boolean => {
const enabled = selectChannelBannerEnabled(state);
if (!enabled) {
return false;
}
const isPremiumLicense = license?.SkuShortName === General.SKUPremium;
const channelBannerInfo = getChannelBanner(state, channelId);
const channel = getChannel(state, channelId);
const isValidChannelType = Boolean(channel && (channel.type === General.OPEN_CHANNEL || channel.type === General.PRIVATE_CHANNEL));
return isPremiumLicense && isValidChannelType && channelBannerEnabled(channelBannerInfo);
return isValidChannelType && channelBannerEnabled(channelBannerInfo);
};

View file

@ -10,6 +10,8 @@ describe('Reducers.RHS', () => {
shouldShowPreviewOnCreateComment: false,
shouldShowPreviewOnCreatePost: false,
shouldShowPreviewOnEditChannelHeaderModal: false,
shouldShowPreviewOnChannelSettingsHeaderModal: false,
shouldShowPreviewOnChannelSettingsPurposeModal: false,
};
test('Initial state', () => {
@ -65,4 +67,34 @@ describe('Reducers.RHS', () => {
shouldShowPreviewOnEditChannelHeaderModal: true,
});
});
test('update show preview value on channel settings header modal', () => {
const nextState = textboxReducer(
{},
{
type: ActionTypes.SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_HEADER_MODAL,
showPreview: true,
},
);
expect(nextState).toEqual({
...initialState,
shouldShowPreviewOnChannelSettingsHeaderModal: true,
});
});
test('update show preview value on channel settings purpose modal', () => {
const nextState = textboxReducer(
{},
{
type: ActionTypes.SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_PURPOSE_MODAL,
showPreview: true,
},
);
expect(nextState).toEqual({
...initialState,
shouldShowPreviewOnChannelSettingsPurposeModal: true,
});
});
});

View file

@ -45,8 +45,34 @@ function shouldShowPreviewOnEditChannelHeaderModal(state = false, action: MMActi
}
}
function shouldShowPreviewOnChannelSettingsHeaderModal(state = false, action: MMAction) {
switch (action.type) {
case ActionTypes.SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_HEADER_MODAL:
return action.showPreview;
case UserTypes.LOGOUT_SUCCESS:
return false;
default:
return state;
}
}
function shouldShowPreviewOnChannelSettingsPurposeModal(state = false, action: MMAction) {
switch (action.type) {
case ActionTypes.SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_PURPOSE_MODAL:
return action.showPreview;
case UserTypes.LOGOUT_SUCCESS:
return false;
default:
return state;
}
}
export default combineReducers({
shouldShowPreviewOnCreateComment,
shouldShowPreviewOnCreatePost,
shouldShowPreviewOnEditChannelHeaderModal,
shouldShowPreviewOnChannelSettingsHeaderModal,
shouldShowPreviewOnChannelSettingsPurposeModal,
});

View file

@ -304,7 +304,6 @@ body.app__body #root {
#channel_view.channel-view {
overflow: hidden;
border-radius: var(--radius-l);
border-left: var(--border-light);
background: var(--center-channel-bg);
box-shadow: var(--elevation-1);
}

View file

@ -5,9 +5,16 @@
input {
height: 36px;
&:not(:focus) {
border-right: none !important;
border-bottom-right-radius: 0 !important;
border-top-right-radius: 0 !important;
}
}
}
.color-icon {
display: inline-block;
width: 24px;
@ -19,8 +26,8 @@
.color-pad {
padding: 5px;
border-color: rgba(var(--center-channel-color-rgb), 0.32) !important;
border-radius: 0 2px 2px 0;
border: var(--border-default);
border-radius: 0 4px 4px 0;
background: functions.v(center-channel-bg) !important;
line-height: 0;
}

View file

@ -14,3 +14,11 @@ export function showPreviewOnCreatePost(state: GlobalState) {
export function showPreviewOnEditChannelHeaderModal(state: GlobalState) {
return state.views.textbox.shouldShowPreviewOnEditChannelHeaderModal;
}
export function showPreviewOnChannelSettingsHeaderModal(state: GlobalState) {
return state.views.textbox.shouldShowPreviewOnChannelSettingsHeaderModal;
}
export function showPreviewOnChannelSettingsPurposeModal(state: GlobalState) {
return state.views.textbox.shouldShowPreviewOnChannelSettingsPurposeModal;
}

View file

@ -217,5 +217,7 @@ export type ViewsState = {
shouldShowPreviewOnCreatePost: boolean;
shouldShowPreviewOnEditChannelHeaderModal: boolean;
shouldShowPreviewOnEditPostModal: boolean;
shouldShowPreviewOnChannelSettingsHeaderModal: boolean;
shouldShowPreviewOnChannelSettingsPurposeModal: boolean;
};
};

View file

@ -265,6 +265,8 @@ export const ActionTypes = keyMirror({
SET_SHOW_PREVIEW_ON_CREATE_COMMENT: null,
SET_SHOW_PREVIEW_ON_CREATE_POST: null,
SET_SHOW_PREVIEW_ON_EDIT_CHANNEL_HEADER_MODAL: null,
SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_HEADER_MODAL: null,
SET_SHOW_PREVIEW_ON_CHANNEL_SETTINGS_PURPOSE_MODAL: null,
TOGGLE_RHS_MENU: null,
OPEN_RHS_MENU: null,
@ -340,6 +342,7 @@ export const ModalIdentifiers = {
CHANNEL_NOTIFICATIONS: 'channel_notifications',
CHANNEL_INVITE: 'channel_invite',
CHANNEL_MEMBERS: 'channel_members',
CHANNEL_SETTINGS: 'channel_settings',
TEAM_MEMBERS: 'team_members',
ADD_USER_TO_CHANNEL: 'add_user_to_channel',
ADD_USER_TO_ROLE: 'add_user_to_role',