diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index 2a5b05bae8d..15b6a5033e9 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -697,6 +697,9 @@ func (a *App) UpdateChannel(c request.CTX, channel *model.Channel) (*model.Chann var invErr *store.ErrInvalidInput switch { case errors.As(err, &invErr): + if invErr.Entity == "Channel" && invErr.Field == "Name" { + return nil, model.NewAppError("UpdateChannel", store.ChannelExistsError, nil, "", http.StatusBadRequest).Wrap(err) + } return nil, model.NewAppError("UpdateChannel", "app.channel.update.bad_id", nil, "", http.StatusBadRequest).Wrap(err) case errors.As(err, &appErr): return nil, appErr diff --git a/webapp/channels/src/components/channel_name_form_field/channel_name_form_field.tsx b/webapp/channels/src/components/channel_name_form_field/channel_name_form_field.tsx index 508dd40f071..ae9d9f7fe9b 100644 --- a/webapp/channels/src/components/channel_name_form_field/channel_name_form_field.tsx +++ b/webapp/channels/src/components/channel_name_form_field/channel_name_form_field.tsx @@ -26,6 +26,7 @@ export type Props = { autoFocus?: boolean; onErrorStateChange?: (isError: boolean) => void; team?: Team; + urlError?: string; } import './channel_name_form_field.scss'; @@ -139,7 +140,7 @@ const ChannelNameFormField = (props: Props): JSX.Element => { pathInfo={url} limit={Constants.MAX_CHANNELNAME_LENGTH} shortenLength={Constants.DEFAULT_CHANNELURL_SHORTEN_LENGTH} - error={urlError} + error={urlError || props.urlError} onChange={handleOnURLChange} /> diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.test.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.test.tsx index 4f4d6809098..024452c0667 100644 --- a/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.test.tsx +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.test.tsx @@ -175,4 +175,43 @@ describe('component/ConvertGmToChannelModal', () => { fireEvent.click(confirmButton!); }); }); + + test('duplicate channel names should npt be allowed', async () => { + TestHelper.initBasic(Client4); + + nock(Client4.getBaseRoute()). + get('/channels/channel_id_1/common_teams'). + reply(200, [ + {id: 'team_id_1', display_name: 'Team 1', name: 'team_1'}, + ]); + + baseProps.actions.convertGroupMessageToPrivateChannel.mockResolvedValueOnce({ + error: { + server_error_id: 'store.sql_channel.save_channel.exists.app_error', + }, + }); + + renderWithFullContext( + , + baseState, + ); + + await waitFor( + () => expect(screen.queryByText('Conversation history will be visible to any channel members')).toBeInTheDocument(), + {timeout: 1500}, + ); + + const channelNameInput = screen.queryByPlaceholderText('Channel name'); + expect(channelNameInput).toBeInTheDocument(); + fireEvent.change(channelNameInput!, {target: {value: 'Channel'}}); + + const confirmButton = screen.queryByText('Convert to private channel'); + expect(channelNameInput).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(confirmButton!); + }); + + expect(screen.queryByText('A channel with that URL already exists')).toBeInTheDocument(); + }); }); diff --git a/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx index 8a53832e4e9..774c7465133 100644 --- a/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx +++ b/webapp/channels/src/components/convert_gm_to_channel_modal/convert_gm_to_channel_modal.tsx @@ -28,6 +28,10 @@ import TeamSelector from 'components/convert_gm_to_channel_modal/team_selector/t import WarningTextSection from 'components/convert_gm_to_channel_modal/warning_text_section/warning_text_section'; import LoadingSpinner from 'components/widgets/loading/loading_spinner'; +const enum ServerErrorId { + CHANNEL_NAME_EXISTS = 'store.sql_channel.save_channel.exists.app_error', +} + export type Props = { onExited: () => void; channel: Channel; @@ -43,8 +47,11 @@ const ConvertGmToChannelModal = (props: Props) => { const [channelName, setChannelName] = useState(''); const channelURL = useRef(''); + + const [urlError, setURLError] = useState(''); const handleChannelURLChange = useCallback((newURL: string) => { channelURL.current = newURL; + setURLError(''); }, []); const [channelMemberNames, setChannelMemberNames] = useState([]); @@ -112,7 +119,17 @@ const ConvertGmToChannelModal = (props: Props) => { const {error} = await props.actions.convertGroupMessageToPrivateChannel(props.channel.id, selectedTeamId, channelName.trim(), channelURL.current.trim()); if (error) { - setConversionError(error.message); + if (error.server_error_id === ServerErrorId.CHANNEL_NAME_EXISTS) { + setURLError( + formatMessage({ + id: 'channel_modal.alreadyExist', + defaultMessage: 'A channel with that URL already exists', + }), + ); + } else { + setConversionError(error.message); + } + return; } @@ -122,7 +139,7 @@ const ConvertGmToChannelModal = (props: Props) => { }, [selectedTeamId, props.channel.id, channelName, channelURL.current, props.actions.moveChannelsInSidebar]); const showLoader = !commonTeamsFetched || !loadingAnimationTimeout; - const canCreate = selectedTeamId !== undefined && channelName !== '' && !nameError; + const canCreate = selectedTeamId !== undefined && channelName !== '' && !nameError && !urlError; const modalProps: Partial> = {}; let modalBody; @@ -171,6 +188,7 @@ const ConvertGmToChannelModal = (props: Props) => { onURLChange={handleChannelURLChange} onErrorStateChange={setNameError} team={selectedTeamId ? commonTeamsById[selectedTeamId] : undefined} + urlError={urlError} /> { diff --git a/webapp/channels/src/components/new_channel_modal/new_channel_modal.scss b/webapp/channels/src/components/new_channel_modal/new_channel_modal.scss index bbc08073272..2f276fe004d 100644 --- a/webapp/channels/src/components/new_channel_modal/new_channel_modal.scss +++ b/webapp/channels/src/components/new_channel_modal/new_channel_modal.scss @@ -81,7 +81,3 @@ font-weight: 500; } } - -.new-channel-modal__url { - margin-top: 4px; -} diff --git a/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx b/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx index 1e93b35662b..0b5966943d1 100644 --- a/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx +++ b/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx @@ -83,6 +83,11 @@ const NewChannelModal = () => { const [canCreateFromPluggable, setCanCreateFromPluggable] = useState(true); const [actionFromPluggable, setActionFromPluggable] = useState<((currentTeamId: string, channelId: string) => Promise) | undefined>(undefined); + const handleURLChange = useCallback((newURL: string) => { + setURL(newURL); + setURLError(''); + }, []); + const handleOnModalConfirm = async () => { if (!canCreate) { return; @@ -265,8 +270,9 @@ const NewChannelModal = () => { name='new-channel-modal-name' placeholder={formatMessage({id: 'channel_modal.name.placeholder', defaultMessage: 'Enter a name for your new channel'})} onDisplayNameChange={setDisplayName} - onURLChange={setURL} + onURLChange={handleURLChange} onErrorStateChange={setChannelInputError} + urlError={urlError} />