mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
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:
parent
f0dfe1c49f
commit
6ae0efd285
84 changed files with 7479 additions and 2464 deletions
|
|
@ -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}');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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)');
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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})`;
|
||||
}
|
||||
|
|
|
|||
2402
e2e-tests/playwright/package-lock.json
generated
2402
e2e-tests/playwright/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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');
|
||||
});
|
||||
12
e2e-tests/playwright/utils/utils.ts
Normal file
12
e2e-tests/playwright/utils/utils.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ export type Props = PropsFromRedux & {
|
|||
*/
|
||||
currentUser: UserProfile;
|
||||
|
||||
/**
|
||||
* Id of the element that triggered the modal opening
|
||||
*/
|
||||
focusOriginElement?: string;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -121,7 +121,6 @@ Object {
|
|||
role="alert"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon error icon-alert-circle-outline"
|
||||
/>
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,5 +217,7 @@ export type ViewsState = {
|
|||
shouldShowPreviewOnCreatePost: boolean;
|
||||
shouldShowPreviewOnEditChannelHeaderModal: boolean;
|
||||
shouldShowPreviewOnEditPostModal: boolean;
|
||||
shouldShowPreviewOnChannelSettingsHeaderModal: boolean;
|
||||
shouldShowPreviewOnChannelSettingsPurposeModal: boolean;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue