Repository: mattermost/mattermost
Pull request #30993: MM-63966 - save pannel not dissapearing after save
Author: login: pvev
Labels: name: 4: Reviews Complete
description: All reviewers have approved the pull request
name: Changelog/Not Needed
description: Does not require a changelog entry
name: Docs/Not Needed
description: Does not require documentation
name: CherryPick/Done
description: Successfully cherry-picked to the quality or patch release tracked in the milestone
name: QA - Review Done
description: QA review is completed but other reviews are outstanding (exception to usual process)
name: release-note-none
description: Denotes a PR that doesn't merit a release note.
Created: 2025-05-12T08:25:03Z | Merged: 2025-05-19T10:26:00Z

#### Summary
adjust the behavior of the channel settings modal so after a successful save, the state values are updated so the check for 'changes' in the form values will return false. Also, when having one of the fields with an empty string at the end, this was causing the save changes panel to show but since we were triming the value before sending to the api, it was causing unnecessary api calls and also the save changes panel to not hide.

#### Ticket Link
https://mattermost.atlassian.net/browse/MM-63966

#### Screenshots
Before:

https://github.com/user-attachments/assets/3574585e-52d9-4d61-9bff-1ae3d8422c19


https://github.com/user-attachments/assets/5742ab91-db51-45c4-ac61-ffe07f3b2d43


After:

https://github.com/user-attachments/assets/f54d91a5-0cd9-49d3-852a-605ac0d2cc8f



https://github.com/user-attachments/assets/26979e12-3b58-4c81-8daa-1d23f782e2cd



#### Release Note
```release-note
NONE
```



Diff:
diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.test.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.test.tsx
index 80d593f7ce6..8255b021f9f 100644
--- a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.test.tsx
+++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.test.tsx
@@ -434,4 +434,48 @@ describe('ChannelSettingsConfigurationTab', () => {
         // Check that the save changes panel is visible
         expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
     });
+
+    it('should trim whitespace from banner text and color when saving', async () => {
+        const {patchChannel} = require('mattermost-redux/actions/channels');
+        patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}});
+
+        renderWithContext(<ChannelSettingsConfigurationTab {...{...baseProps, channel: mockChannelWithBanner}}/>);
+
+        // Add whitespace to the banner text
+        await act(async () => {
+            const textInput = screen.getByTestId('channel_banner_banner_text_textbox');
+            await userEvent.clear(textInput);
+            await userEvent.type(textInput, '  Banner text with whitespace  ');
+        });
+
+        // Add whitespace to the banner color
+        await act(async () => {
+            const colorInput = screen.getByTestId('color-inputColorValue');
+            await userEvent.clear(colorInput);
+            await userEvent.type(colorInput, '  #00FF00  ');
+        });
+
+        // Click the Save button
+        await act(async () => {
+            await userEvent.click(screen.getByRole('button', {name: 'Save'}));
+        });
+
+        // Verify patchChannel was called with the trimmed values
+        expect(patchChannel).toHaveBeenCalledWith('channel1', {
+            ...mockChannelWithBanner,
+            banner_info: {
+                enabled: true,
+                text: 'Banner text with whitespace', // Whitespace should be trimmed
+                background_color: expect.any(String), // The exact color might be normalized by the component
+            },
+        });
+
+        // Verify that the local state is updated with trimmed values
+        // Wait for the component to update after the save
+        await new Promise((resolve) => setTimeout(resolve, 0));
+
+        // The text input should now have the trimmed value
+        const textInput = screen.getByTestId('channel_banner_banner_text_textbox');
+        expect(textInput).toHaveValue('Banner text with whitespace');
+    });
 });
diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx
index e7b35966e93..32e1facc558 100644
--- a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx
+++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx
@@ -75,18 +75,19 @@ function ChannelSettingsConfigurationTab({channel, setAreThereUnsavedChanges, sh
     }, []);
 
     const handleTextChange = useCallback((e: React.ChangeEvent<TextboxElement>) => {
-        setUpdatedChannelBanner({
-            ...updatedChannelBanner,
-            text: e.target.value,
-        });
+        const newValue = e.target.value;
+        setUpdatedChannelBanner((prev) => ({
+            ...prev,
+            text: newValue,
+        }));
 
-        if (e.target.value.trim().length > CHANNEL_BANNER_MAX_CHARACTER_LIMIT) {
+        if (newValue.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) {
+        } else if (newValue.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',
@@ -96,24 +97,24 @@ function ChannelSettingsConfigurationTab({channel, setAreThereUnsavedChanges, sh
             resetFormErrors();
             setCharacterLimitExceeded(false);
         }
-    }, [formatMessage, resetFormErrors, updatedChannelBanner]);
+    }, [formatMessage, resetFormErrors]);
 
     const handleColorChange = useCallback((color: string) => {
-        setUpdatedChannelBanner({
-            ...updatedChannelBanner,
+        setUpdatedChannelBanner((prev) => ({
+            ...prev,
             background_color: color,
-        });
+        }));
 
-        if (color) {
+        if (color.trim()) {
             resetFormErrors();
         }
-    }, [resetFormErrors, updatedChannelBanner]);
+    }, [resetFormErrors]);
 
     const toggleTextPreview = useCallback(() => setShowBannerTextPreview((show) => !show), []);
 
     const hasUnsavedChanges = useCallback(() => {
-        return updatedChannelBanner.text !== initialBannerInfo?.text ||
-            updatedChannelBanner.background_color !== initialBannerInfo?.background_color ||
+        return (updatedChannelBanner.text?.trim() || '') !== (initialBannerInfo?.text?.trim() || '') ||
+            (updatedChannelBanner.background_color?.trim() || '') !== (initialBannerInfo?.background_color?.trim() || '') ||
             updatedChannelBanner.enabled !== initialBannerInfo?.enabled;
     }, [initialBannerInfo, updatedChannelBanner]);
 
@@ -154,8 +155,8 @@ function ChannelSettingsConfigurationTab({channel, setAreThereUnsavedChanges, sh
         };
 
         updated.banner_info = {
-            text: updatedChannelBanner.text,
-            background_color: updatedChannelBanner.background_color,
+            text: updatedChannelBanner.text?.trim() || '',
+            background_color: updatedChannelBanner.background_color?.trim() || '',
             enabled: updatedChannelBanner.enabled,
         };
 
@@ -175,6 +176,13 @@ function ChannelSettingsConfigurationTab({channel, setAreThereUnsavedChanges, sh
             return;
         }
 
+        // Update local state with trimmed values after successful save
+        setUpdatedChannelBanner((prev) => ({
+            ...prev,
+            text: prev.text?.trim() || '',
+            background_color: prev.background_color?.trim() || '',
+        }));
+
         resetFormErrors();
         setSaveChangesPanelState('saved');
     }, [handleSave, resetFormErrors]);
diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.test.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.test.tsx
index 2fc8809a401..c0850e65ada 100644
--- a/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.test.tsx
+++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.test.tsx
@@ -213,6 +213,89 @@ describe('ChannelSettingsInfoTab', () => {
         });
     });
 
+    it('should trim whitespace from channel fields when saving', async () => {
+        const {patchChannel} = require('mattermost-redux/actions/channels');
+        patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}});
+
+        renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
+
+        // Add whitespace to the channel fields
+        await act(async () => {
+            // Change the channel name with whitespace
+            const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
+            await userEvent.clear(nameInput);
+            await userEvent.type(nameInput, '  Channel Name With Whitespace  ');
+
+            // Change the channel purpose with whitespace
+            const purposeInput = screen.getByTestId('channel_settings_purpose_textbox');
+            await userEvent.clear(purposeInput);
+            await userEvent.type(purposeInput, '  Purpose with whitespace  ');
+
+            // Change the channel header with whitespace
+            const headerInput = screen.getByTestId('channel_settings_header_textbox');
+            await userEvent.clear(headerInput);
+            await userEvent.type(headerInput, '  Header with whitespace  ');
+        });
+
+        // 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'}));
+        });
+
+        // Verify patchChannel was called with the trimmed values
+        expect(patchChannel).toHaveBeenCalledWith('channel1', {
+            ...mockChannel,
+            display_name: 'Channel Name With Whitespace', // Whitespace should be trimmed
+            name: 'channel-name-with-whitespace', // URL is generated from display name and should be trimmed
+            purpose: 'Purpose with whitespace', // Whitespace should be trimmed
+            header: 'Header with whitespace', // Whitespace should be trimmed
+        });
+
+        // Verify that the local state is updated with trimmed values
+        // Wait for the component to update after the save
+        await new Promise((resolve) => setTimeout(resolve, 0));
+
+        // The inputs should now have the trimmed values
+        expect(screen.getByRole('textbox', {name: 'Channel name'})).toHaveValue('Channel Name With Whitespace');
+        expect(screen.getByTestId('channel_settings_purpose_textbox')).toHaveValue('Purpose with whitespace');
+        expect(screen.getByTestId('channel_settings_header_textbox')).toHaveValue('Header with whitespace');
+    });
+
+    it('should hide SaveChangesPanel after successful save', async () => {
+        // Mock the patchChannel function to return a successful response
+        const {patchChannel} = require('mattermost-redux/actions/channels');
+        patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}});
+
+        renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
+
+        // Initially, SaveChangesPanel should not be visible
+        expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
+
+        // Make changes to the channel name
+        await act(async () => {
+            const nameInput = screen.getByRole('textbox', {name: 'Channel name'});
+            await userEvent.clear(nameInput);
+            await userEvent.type(nameInput, 'Updated Channel Name');
+        });
+
+        // SaveChangesPanel should now be visible
+        expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
+
+        // Click the Save button
+        await act(async () => {
+            await userEvent.click(screen.getByRole('button', {name: 'Save'}));
+        });
+
+        // Add a small delay to ensure all state updates are processed
+        await new Promise((resolve) => setTimeout(resolve, 0));
+
+        // SaveChangesPanel should now be hidden after the successful save
+        expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
+    });
+
     it('should reset form when Reset button is clicked', async () => {
         renderWithContext(<ChannelSettingsInfoTab {...baseProps}/>);
 
diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.tsx
index ba4bc92115d..0f30db0922d 100644
--- a/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.tsx
+++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.tsx
@@ -106,10 +106,10 @@ function ChannelSettingsInfoTab({
     useEffect(() => {
         // Calculate unsaved changes directly
         const unsavedChanges = channel ? (
-            displayName !== channel.display_name ||
-            channelUrl !== channel.name ||
-            channelPurpose !== channel.purpose ||
-            channelHeader !== channel.header ||
+            displayName.trim() !== channel.display_name ||
+            channelUrl.trim() !== channel.name ||
+            channelPurpose.trim() !== channel.purpose ||
+            channelHeader.trim() !== channel.header ||
             channelType !== channel.type
         ) : false;
 
@@ -122,7 +122,7 @@ function ChannelSettingsInfoTab({
             setSaveChangesPanelState(undefined);
             setUrlError('');
         }
-        setChannelURL(newURL);
+        setChannelURL(newURL.trim());
     }, [internalUrlError]);
 
     const togglePurposePreview = useCallback(() => {
@@ -155,7 +155,7 @@ function ChannelSettingsInfoTab({
         setChannelHeader(newValue);
 
         // Check for character limit
-        if (newValue.length > HEADER_MAX_LENGTH) {
+        if (newValue.trim().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.',
@@ -176,7 +176,7 @@ function ChannelSettingsInfoTab({
         setChannelPurpose(newValue);
 
         // Check for character limit
-        if (newValue.length > Constants.MAX_CHANNELPURPOSE_LENGTH) {
+        if (newValue.trim().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.',
@@ -243,6 +243,12 @@ function ChannelSettingsInfoTab({
             return false;
         }
 
+        // After every successful save, update local state to match the saved values
+        // with this, we make sure that the unsavedChanges check will return false after saving
+        setDisplayName(updated.display_name);
+        setChannelURL(updated.name);
+        setChannelPurpose(updated.purpose);
+        setChannelHeader(updated.header);
         return true;
     }, [channel, displayName, channelUrl, channelPurpose, channelHeader, channelType, setFormError, handleServerError]);
 
@@ -308,10 +314,10 @@ function ChannelSettingsInfoTab({
     // 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 ||
+            displayName.trim() !== channel.display_name ||
+            channelUrl.trim() !== channel.name ||
+            channelPurpose.trim() !== channel.purpose ||
+            channelHeader.trim() !== channel.header ||
             channelType !== channel.type
         ) : false;
 
@@ -445,7 +451,7 @@ function ChannelSettingsInfoTab({
             />
 
             {/* SaveChangesPanel for unsaved changes */}
-            {shouldShowPanel && (
+            {(canManageChannelProperties && shouldShowPanel) && (
                 <SaveChangesPanel
                     handleSubmit={handleSaveChanges}
                     handleCancel={handleCancel}


Reviews:
user: login: yasserfaraazkhan
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T10:36:20Z
user: login: harshilsharma63
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-13T01:02:59Z
user: login: isacikgoz
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-13T10:49:22Z
user: login: harshilsharma63
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-16T04:54:27Z

Review comments:
diff_hunk: @@ -106,10 +106,10 @@ function ChannelSettingsInfoTab({
     useEffect(() => {
         // Calculate unsaved changes directly
         const unsavedChanges = channel ? (
-            displayName !== channel.display_name ||
-            channelUrl !== channel.name ||
-            channelPurpose !== channel.purpose ||
-            channelHeader !== channel.header ||
+            displayName.trim() !== channel.display_name ||
path: webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.tsx
user: login: harshilsharma63
body: For all changed files-

It will be better if we saved the trimmed value in state rather then remembering to always trim the value before using it in all the places. This will minimize the required changes, and eliminate the error when forgets to trim the value before using it (which will definitely happen at some point)
created_at: 2025-05-13T01:02:32Z
author_association: MEMBER
diff_hunk: @@ -112,8 +112,8 @@ function ChannelSettingsConfigurationTab({channel, setAreThereUnsavedChanges, sh
     const toggleTextPreview = useCallback(() => setShowBannerTextPreview((show) => !show), []);
 
     const hasUnsavedChanges = useCallback(() => {
-        return updatedChannelBanner.text !== initialBannerInfo?.text ||
-            updatedChannelBanner.background_color !== initialBannerInfo?.background_color ||
+        return (updatedChannelBanner.text?.trim() || '') !== (initialBannerInfo?.text?.trim() || '') ||
path: webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx
user: login: harshilsharma63
body: Some comment - save the trimmed value rather than always trimming before every single use.
created_at: 2025-05-13T01:02:54Z
author_association: MEMBER

PR comments:
user: login: unified-ci-app[bot]
created_at: 2025-05-12T10:03:51Z
body: E2E test triggered successfully for PR #30993. The corresponding commit's status check will be available shortly.

author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-12T10:04:43Z
body: E2E test run is starting for commit `45caa8a9f7bf52609415c6a4ab6c030851010eeb`.
To check the run progress:
- Cypress: look for commit status `E2E Tests/test` or the access the [Automation Dashboard Cycle URL](https://automation-dashboard.vercel.app/cycle/14969417526_1-45caa8a-pr-onprem-ent).
- Playwright: look for commit status `E2E Tests/test-playwright`.

You can also look at the [E2E test's Workflow Run URL](https://github.com/mattermost/mattermost/actions/runs/14969417526) (run ID `14969417526`).

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-12T10:52:33Z
body: E2E test has completed for commit `45caa8a9f7bf52609415c6a4ab6c030851010eeb`.
Results summary:
- Cypress: pass rate is `99.95%` (see [Automation Dashboard](https://automation-dashboard.vercel.app/cycle/14969417526_1-45caa8a-pr-onprem-ent) and commit status check `E2E Tests/test`).
- Playwright: pass rate is `97.85%` (see commit status check `E2E Tests/test-playwright`).

The run summary artifacts are available in the corresponding [Workflow Run](https://github.com/mattermost/mattermost/actions/runs/14969417526).

author_association: NONE
user: login: pvev
created_at: 2025-05-15T10:38:14Z
body: /update-branch
author_association: CONTRIBUTOR
user: login: pvev
created_at: 2025-05-19T08:41:19Z
body: /update-branch
author_association: CONTRIBUTOR
user: login: unified-ci-app[bot]
created_at: 2025-05-19T08:41:43Z
body: E2E tests requested: they will start automatically when status check `Enterprise CI/docker-image` is successful.

author_association: CONTRIBUTOR
user: login: unified-ci-app[bot]
created_at: 2025-05-19T09:02:02Z
body: Docker image is now available: E2E test triggered successfully for PR #30993. The corresponding commit's status check will be available shortly.

author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-19T09:02:52Z
body: E2E test run is starting for commit `42b761ee955a5bb31cf7dc098c4c1aaa26315d50`.
To check the run progress:
- Cypress: look for commit status `E2E Tests/test` or the access the [Automation Dashboard Cycle URL](https://automation-dashboard.vercel.app/cycle/15108813812_1-42b761e-pr-onprem-ent).
- Playwright: look for commit status `E2E Tests/test-playwright`.

You can also look at the [E2E test's Workflow Run URL](https://github.com/mattermost/mattermost/actions/runs/15108813812) (run ID `15108813812`).

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-19T09:51:10Z
body: E2E test has completed for commit `42b761ee955a5bb31cf7dc098c4c1aaa26315d50`.
Results summary:
- Cypress: pass rate is `100.00%` (see [Automation Dashboard](https://automation-dashboard.vercel.app/cycle/15108813812_1-42b761e-pr-onprem-ent) and commit status check `E2E Tests/test`).
- Playwright: pass rate is `100.00%` (see commit status check `E2E Tests/test-playwright`).

The run summary artifacts are available in the corresponding [Workflow Run](https://github.com/mattermost/mattermost/actions/runs/15108813812).

author_association: NONE
user: login: mattermost-build
created_at: 2025-05-19T10:26:02Z
body: Cherry pick is scheduled.
author_association: CONTRIBUTOR
user: login: mm-cloud-bot
created_at: 2025-05-19T10:26:06Z
body: Test server destroyed
author_association: NONE