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 80d593f7ce..8255b021f9 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(); + + // 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 e7b35966e9..32e1facc55 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) => { - 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 2fc8809a40..c0850e65ad 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(); + + // 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(); + + // 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(); 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 ba4bc92115..0f30db0922 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) && (