diff --git a/e2e-tests/cypress/tests/integration/channels/channel/channel_info_rhs_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel/channel_info_rhs_spec.ts index b150617a02c..6f345a3e0ca 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/channel_info_rhs_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/channel/channel_info_rhs_spec.ts @@ -227,6 +227,39 @@ describe('Channel Info RHS', () => { cy.uiGetRHS().findByText('header for the tests').should('be.visible'); }); }); + it('should be able to rename channel from About area', () => { + // # Create a dedicated channel for renaming to avoid affecting other tests + cy.apiCreateChannel(testTeam.id, 'channel-to-rename', 'Channel To Rename', 'O').then(({channel}) => { + cy.apiAddUserToChannel(channel.id, admin.id); + + // # Go to the channel + cy.visit(`/${testTeam.name}/channels/${channel.name}`); + + // # Open Channel Info RHS + cy.get('#channel-info-btn').click(); + + // # Click edit on channel name (first Edit in About) + cy.uiGetRHS().findAllByLabelText('Edit').first().click({force: true}); + + // * Rename Channel modal appears + cy.findByRole('heading', {name: /rename channel/i}).should('be.visible'); + + // # Fill display name and URL + cy.findByPlaceholderText(/enter display name/i).clear().type('Renamed Channel'); + cy.get('.url-input-button').click(); + cy.get('.url-input-container input').clear().type('renamed-channel'); + cy.get('.url-input-container button.url-input-button').click(); + + // # Save + cy.findByRole('button', {name: /save/i}).click(); + + // * URL updated + cy.location('pathname').should('include', `/${testTeam.name}/channels/renamed-channel`); + + // * Header shows new name + cy.get('#channelHeaderTitle').should('contain', 'Renamed Channel'); + }); + }); }); describe('bottom menu', () => { it('should be able to manage notifications', () => { @@ -237,11 +270,29 @@ describe('Channel Info RHS', () => { cy.get('#channel-info-btn').click(); // # Click on "Notification Preferences" - cy.uiGetRHS().findByText('Notification Preferences').should('be.visible').click(); + cy.uiGetRHS().findByTestId('channel_info_rhs-menu').findByText('Notification Preferences').scrollIntoView().should('be.visible').click(); // * Ensures the modal is there cy.get('.ChannelNotificationModal').should('be.visible'); }); + it('should open Channel Settings from RHS menu', () => { + // # Go to test channel + cy.visit(`/${testTeam.name}/channels/${testChannel.name}`); + + // # Close RHS if it's open, then click on the channel info button + cy.get('body').then(($body) => { + if ($body.find('#rhsCloseButton').length > 0) { + cy.get('#rhsCloseButton').click(); + } + cy.get('#channel-info-btn').should('be.visible').click(); + }); + + // * Channel Settings item is visible in RHS menu + cy.uiGetRHS().findByTestId('channel_info_rhs-menu').findByText('Channel Settings').scrollIntoView().should('be.visible').click(); + + // * Channel Settings modal opens + cy.get('.ChannelSettingsModal').should('be.visible'); + }); it('should be able to view files and come back', () => { // # Go to test channel cy.visit(`/${testTeam.name}/channels/${testChannel.name}`); @@ -250,7 +301,7 @@ describe('Channel Info RHS', () => { cy.get('#channel-info-btn').click(); // # Click on "Files" - cy.uiGetRHS().findByTestId('channel_info_rhs-menu').findByText('Files').should('be.visible').click(); + cy.uiGetRHS().findByTestId('channel_info_rhs-menu').findByText('Files').scrollIntoView().should('be.visible').click(); // * Ensure we see the files RHS cy.uiGetRHS().findByText('No files yet').should('be.visible'); @@ -277,10 +328,10 @@ describe('Channel Info RHS', () => { cy.get('#channel-info-btn').click(); // # Click on "Pinned Messages" - cy.uiGetRHS().findByTestId('channel_info_rhs-menu').findByText('Pinned messages').should('be.visible').click(); + cy.uiGetRHS().findByTestId('channel_info_rhs-menu').findByText('Pinned messages').scrollIntoView().should('be.visible').click(); // * Ensure we see the Pinned Post RHS - cy.uiGetRHS().findByText('Hello channel info rhs spec').should('be.visible'); + cy.uiGetRHS().findByText('Hello channel info rhs spec').first().should('be.visible'); // # Click the Back Icon cy.uiGetRHS().get('[aria-label="Back Icon"]').click(); @@ -296,7 +347,7 @@ describe('Channel Info RHS', () => { cy.get('#channel-info-btn').click(); // # Click on "Members" - cy.uiGetRHS().findByTestId('channel_info_rhs-menu').findByText('Members').should('be.visible').click(); + cy.uiGetRHS().findByTestId('channel_info_rhs-menu').findByText('Members').scrollIntoView().should('be.visible').click(); // * Ensure we see the members cy.uiGetRHS().contains('sysadmin').should('be.visible'); @@ -399,7 +450,7 @@ describe('Channel Info RHS', () => { cy.get('#channel-info-btn').click(); // # Click on "Notification Preferences" - cy.uiGetRHS().findByText('Notification Preferences').should('be.visible').click(); + cy.uiGetRHS().findByTestId('channel_info_rhs-menu').findByText('Notification Preferences').scrollIntoView().should('be.visible').click(); // * Ensures the modal is there cy.get('.ChannelNotificationModal').should('be.visible'); @@ -489,6 +540,6 @@ describe('Channel Info RHS', () => { function ensureRHSIsOpenOnChannelInfo(testChannel) { cy.get('#rhsContainer').then((rhsContainer) => { cy.wrap(rhsContainer).findByText('Info').should('be.visible'); - cy.wrap(rhsContainer).findByText(testChannel.display_name).should('be.visible'); + cy.wrap(rhsContainer).find('.sidebar--right__title__subtitle').should('contain', testChannel.display_name); }); } diff --git a/webapp/channels/src/components/channel_info_rhs/about_area.tsx b/webapp/channels/src/components/channel_info_rhs/about_area.tsx index b5d5d4a7eec..08af1e86d0b 100644 --- a/webapp/channels/src/components/channel_info_rhs/about_area.tsx +++ b/webapp/channels/src/components/channel_info_rhs/about_area.tsx @@ -40,6 +40,7 @@ interface Props { gmUsers?: UserProfile[]; canEditChannelProperties: boolean; actions: { + editChannelName: () => void; editChannelPurpose: () => void; editChannelHeader: () => void; }; diff --git a/webapp/channels/src/components/channel_info_rhs/about_area_channel.test.tsx b/webapp/channels/src/components/channel_info_rhs/about_area_channel.test.tsx index 83629fd5f0b..506e32fb358 100644 --- a/webapp/channels/src/components/channel_info_rhs/about_area_channel.test.tsx +++ b/webapp/channels/src/components/channel_info_rhs/about_area_channel.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import type {Channel} from '@mattermost/types/channels'; import type {DeepPartial} from '@mattermost/types/utilities'; -import {renderWithContext, screen} from 'tests/react_testing_utils'; +import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils'; import type {GlobalState} from 'types/store'; @@ -112,12 +112,15 @@ describe('channel_info_rhs/about_area_channel', () => { const defaultProps = { channel: { id: 'test-c-id', + name: 'my-channel', header: 'my channel header', purpose: 'my channel purpose', + display_name: 'My Channel', } as Channel, channelURL: 'https://my-url.mm', canEditChannelProperties: true, actions: { + editChannelName: jest.fn(), editChannelPurpose: jest.fn(), editChannelHeader: jest.fn(), }, @@ -144,4 +147,25 @@ describe('channel_info_rhs/about_area_channel', () => { expect(screen.getByText('my channel header')).toBeInTheDocument(); }); + + test('should trigger editChannelName when clicking channel display name', () => { + const props = { + ...defaultProps, + actions: { + ...defaultProps.actions, + editChannelName: jest.fn(), + }, + }; + + renderWithContext( + , + initialState, + ); + + const editButtons = screen.getAllByLabelText('Edit'); + fireEvent.click(editButtons[0]); + expect(props.actions.editChannelName).toHaveBeenCalled(); + }); }); diff --git a/webapp/channels/src/components/channel_info_rhs/about_area_channel.tsx b/webapp/channels/src/components/channel_info_rhs/about_area_channel.tsx index 194eae5ca35..dd8bcd5b2b6 100644 --- a/webapp/channels/src/components/channel_info_rhs/about_area_channel.tsx +++ b/webapp/channels/src/components/channel_info_rhs/about_area_channel.tsx @@ -7,17 +7,36 @@ import styled from 'styled-components'; import type {Channel} from '@mattermost/types/channels'; +import CopyButton from 'components/copy_button'; import Markdown from 'components/markdown'; import EditableArea from './components/editable_area'; import LineLimiter from './components/linelimiter'; -const ChannelId = styled.div` +const ChannelName = styled.div` margin-bottom: 12px; + font-size: 20px; + font-family: Metropolis, sans-serif; + font-weight: 600; + letter-spacing: -0.01em; +`; + +const ChannelId = styled.div` + padding: 4px 0; + margin-bottom: 8px; font-size: 11px; line-height: 16px; letter-spacing: 0.02em; color: rgba(var(--center-channel-color-rgb), 0.75); + &:not(:last-child) { + margin-bottom: 0px; + } + .post-code__clipboard { + opacity: 0; + } + &:hover .post-code__clipboard { + opacity: 1; + } `; const ChannelPurpose = styled.div` @@ -29,23 +48,36 @@ const ChannelPurpose = styled.div` const ChannelDescriptionHeading = styled.div` color: rgba(var(--center-channel-color-rgb), 0.75); - font-size: 12px; + font-size: 11px; font-style: normal; font-weight: 600; line-height: 16px; letter-spacing: 0.24px; text-transform: uppercase; - padding: 6px 0px; + padding: 4px 0px; `; const ChannelHeader = styled.div` margin-bottom: 12px; `; +const SmallCopyButton = styled(CopyButton)` + i { + font-size: 14px; + margin-left: 4px; + color: rgba(var(--center-channel-color-rgb), 0.64); + + &:hover { + color: rgba(var(--center-channel-color-rgb), 0.88); + } + } +`; + interface Props { channel: Channel; canEditChannelProperties: boolean; actions: { + editChannelName: () => void; editChannelPurpose: () => void; editChannelHeader: () => void; }; @@ -56,6 +88,16 @@ const AboutAreaChannel = ({channel, canEditChannelProperties, actions}: Props) = return ( <> + + {channel.display_name}} + onEdit={actions.editChannelName} + editTooltip={formatMessage({id: 'channel_info_rhs.about_area.edit_channel_name', defaultMessage: 'Rename channel'})} + emptyLabel={formatMessage({id: 'channel_info_rhs.about_area.edit_channel_name', defaultMessage: 'Rename channel'})} + /> + + {(channel.purpose || canEditChannelProperties) && ( @@ -74,6 +116,7 @@ const AboutAreaChannel = ({channel, canEditChannelProperties, actions}: Props) = )} onEdit={actions.editChannelPurpose} + editTooltip={formatMessage({id: 'channel_info_rhs.about_area.edit_channel_purpose', defaultMessage: 'Edit channel purpose'})} emptyLabel={formatMessage({id: 'channel_info_rhs.about_area.add_channel_purpose', defaultMessage: 'Add a channel purpose'})} /> @@ -97,14 +140,27 @@ const AboutAreaChannel = ({channel, canEditChannelProperties, actions}: Props) = )} editable={canEditChannelProperties} onEdit={actions.editChannelHeader} + editTooltip={formatMessage({id: 'channel_info_rhs.about_area.edit_channel_header', defaultMessage: 'Edit channel header'})} emptyLabel={formatMessage({id: 'channel_info_rhs.about_area.add_channel_header', defaultMessage: 'Add a channel header'})} /> )} - {formatMessage({id: 'channel_info_rhs.about_area_id', defaultMessage: 'ID:'})} {channel.id} + {formatMessage({id: 'channel_info_rhs.about_area_handle', defaultMessage: 'Channel handle:'})} {channel.name} + + + {formatMessage({id: 'channel_info_rhs.about_area_id', defaultMessage: 'ID:'})} {channel.id} + + + ); }; diff --git a/webapp/channels/src/components/channel_info_rhs/about_area_dm.tsx b/webapp/channels/src/components/channel_info_rhs/about_area_dm.tsx index 1119d30c373..683172f2c90 100644 --- a/webapp/channels/src/components/channel_info_rhs/about_area_dm.tsx +++ b/webapp/channels/src/components/channel_info_rhs/about_area_dm.tsx @@ -128,6 +128,7 @@ const AboutAreaDM = ({channel, dmUser, actions}: Props) => { )} editable={true} onEdit={actions.editChannelHeader} + editTooltip={formatMessage({id: 'channel_info_rhs.about_area.edit_channel_header', defaultMessage: 'Edit channel header'})} emptyLabel={formatMessage({id: 'channel_info_rhs.about_area.add_channel_header', defaultMessage: 'Add a channel header'})} /> diff --git a/webapp/channels/src/components/channel_info_rhs/about_area_gm.tsx b/webapp/channels/src/components/channel_info_rhs/about_area_gm.tsx index 556a8c1dca2..703cfa54e8c 100644 --- a/webapp/channels/src/components/channel_info_rhs/about_area_gm.tsx +++ b/webapp/channels/src/components/channel_info_rhs/about_area_gm.tsx @@ -120,6 +120,7 @@ const AboutAreaGM = ({channel, gmUsers, actions}: Props) => { )} editable={true} onEdit={actions.editChannelHeader} + editTooltip={formatMessage({id: 'channel_info_rhs.about_area.edit_channel_header', defaultMessage: 'Edit channel header'})} emptyLabel={formatMessage({id: 'channel_info_rhs.about_area.add_channel_header', defaultMessage: 'Add a channel header'})} /> diff --git a/webapp/channels/src/components/channel_info_rhs/channel_info_rhs.test.tsx b/webapp/channels/src/components/channel_info_rhs/channel_info_rhs.test.tsx index 79d1a899d61..47e071cee56 100644 --- a/webapp/channels/src/components/channel_info_rhs/channel_info_rhs.test.tsx +++ b/webapp/channels/src/components/channel_info_rhs/channel_info_rhs.test.tsx @@ -8,6 +8,7 @@ import type {Team} from '@mattermost/types/teams'; import type {UserProfile} from '@mattermost/types/users'; import {act, renderWithContext} from 'tests/react_testing_utils'; +import {ModalIdentifiers} from 'utils/constants'; import ChannelInfoRHS from './channel_info_rhs'; @@ -48,6 +49,7 @@ describe('channel_info_rhs', () => { beforeEach(() => { props = {...OriginalProps}; + mockAboutArea.mockClear(); }); describe('about area', () => { @@ -88,4 +90,27 @@ describe('channel_info_rhs', () => { ); }); }); + + test('editChannelName opens Rename Channel modal', () => { + props.currentTeam = {name: 'team-1'} as Team; + renderWithContext( + , + ); + + // Invoke the handler passed into the mocked AboutArea + const lastArgs = mockAboutArea.mock.calls[mockAboutArea.mock.calls.length - 1][0]; + lastArgs.actions.editChannelName(); + + expect(props.actions.openModal).toHaveBeenCalledWith( + expect.objectContaining({ + modalId: ModalIdentifiers.RENAME_CHANNEL, + dialogProps: expect.objectContaining({ + channel: props.channel, + teamName: 'team-1', + }), + }), + ); + }); }); diff --git a/webapp/channels/src/components/channel_info_rhs/channel_info_rhs.tsx b/webapp/channels/src/components/channel_info_rhs/channel_info_rhs.tsx index 903b87b792a..1443b391220 100644 --- a/webapp/channels/src/components/channel_info_rhs/channel_info_rhs.tsx +++ b/webapp/channels/src/components/channel_info_rhs/channel_info_rhs.tsx @@ -2,18 +2,24 @@ // See LICENSE.txt for license information. import React, {memo} from 'react'; +import {FormattedMessage} from 'react-intl'; import styled from 'styled-components'; import type {Channel, ChannelStats} from '@mattermost/types/channels'; import type {Team} from '@mattermost/types/teams'; import type {UserProfile} from '@mattermost/types/users'; +import {Permissions} from 'mattermost-redux/constants'; + import ChannelInviteModal from 'components/channel_invite_modal'; import ChannelNotificationsModal from 'components/channel_notifications_modal'; import Scrollbars from 'components/common/scrollbars'; import EditChannelHeaderModal from 'components/edit_channel_header_modal'; import EditChannelPurposeModal from 'components/edit_channel_purpose_modal'; import MoreDirectChannels from 'components/more_direct_channels'; +import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate'; +import RenameChannelModal from 'components/rename_channel_modal'; +import UnarchiveChannelModal from 'components/unarchive_channel_modal'; import Constants, {ModalIdentifiers} from 'utils/constants'; import {getSiteURL} from 'utils/url'; @@ -25,12 +31,44 @@ import Header from './header'; import Menu from './menu'; import TopButtons from './top_buttons'; +const Container = styled.div` + display: flex; + flex-direction: column; + flex: 1; + overflow-y: auto; +`; + const Divider = styled.div` width: 88%; border: 1px solid rgba(var(--center-channel-color-rgb), 0.04); margin: 0 auto; `; +const ArchivedNoticeContainer = styled.div` + margin: 24px 24px 0 24px; +`; + +const ArchivedNotice = styled.div` + .sectionNoticeIcon { + width: 24px; + height: 24px; + } + + .sectionNoticeTitle { + color: rgba(var(--center-channel-color-rgb), 0.88); + display: inline; + align-items: center; + gap: 8px; + } + + .sectionNoticeTitle .sectionNoticeButton { + margin: 0; + padding: 0; + display: inline; + margin: 0 0 2px 4px; + } +`; + export interface DMUser { user: UserProfile; display_name: string; @@ -129,12 +167,24 @@ const ChannelInfoRhs = ({ dialogProps: {channel}, }); + const editChannelName = () => actions.openModal({ + modalId: ModalIdentifiers.RENAME_CHANNEL, + dialogType: RenameChannelModal, + dialogProps: {channel, teamName: currentTeam.name}, + }); + const openNotificationSettings = () => actions.openModal({ modalId: ModalIdentifiers.CHANNEL_NOTIFICATIONS, dialogType: ChannelNotificationsModal, dialogProps: {channel, currentUser, focusOriginElement: 'channelInfoRHSNotificationSettings'}, }); + const openUnarchiveChannel = () => actions.openModal({ + modalId: ModalIdentifiers.UNARCHIVE_CHANNEL, + dialogType: UnarchiveChannelModal, + dialogProps: {channel}, + }); + const gmUsers = channelMembers.filter((user) => { return user.id !== currentUser.id; }); @@ -148,45 +198,83 @@ const ChannelInfoRhs = ({ >
- - - - + + {isArchived && ( + + + +
+

+ + {channel.name !== Constants.DEFAULT_CHANNEL && ( + + + + )} +

+
+
+
+ )} + + + + + ); diff --git a/webapp/channels/src/components/channel_info_rhs/components/editable_area.tsx b/webapp/channels/src/components/channel_info_rhs/components/editable_area.tsx index a2cd1663df8..eae2029d5e7 100644 --- a/webapp/channels/src/components/channel_info_rhs/components/editable_area.tsx +++ b/webapp/channels/src/components/channel_info_rhs/components/editable_area.tsx @@ -5,13 +5,17 @@ import React from 'react'; import {useIntl} from 'react-intl'; import styled from 'styled-components'; +import WithTooltip from 'components/with_tooltip'; + const EditButton = styled.button` border: 0; margin: 0px; padding: 0px; border-radius: 4px; - background: rgba(var(--center-channel-color-rgb), 0.04); - color: rgba(var(--center-channel-color-rgb), 0.75); + background: none; + position: relative; + top: -2px; + color: rgba(var(--center-channel-color-rgb), 0.64); &:hover { background: rgba(var(--center-channel-color-rgb), 0.08); color: rgba(var(--center-channel-color-rgb), 0.75); @@ -47,9 +51,10 @@ interface EditableAreaProps { emptyLabel: string; onEdit: () => void; className?: string; + editTooltip?: string; } -const EditableAreaBase = ({editable, content, emptyLabel, onEdit, className}: EditableAreaProps) => { +const EditableAreaBase = ({editable, content, emptyLabel, onEdit, className, editTooltip}: EditableAreaProps) => { const {formatMessage} = useIntl(); const allowEditArea = editable && content; @@ -70,12 +75,14 @@ const EditableAreaBase = ({editable, content, emptyLabel, onEdit, className}: Ed
{allowEditArea ? ( - - - + + + + + ) : ''}
@@ -90,14 +97,13 @@ const EditableArea = styled(EditableAreaBase)` margin-bottom:0; } } - &:hover { - &>.EditableArea__edit { - visibility: visible; - } + &:hover > .EditableArea__edit, + &:focus-within > .EditableArea__edit { + opacity: 1; } &>.EditableArea__edit { - visibility: hidden; + opacity: 0; width: 24px; } `; diff --git a/webapp/channels/src/components/channel_info_rhs/header.test.tsx b/webapp/channels/src/components/channel_info_rhs/header.test.tsx index 542770a9785..9e2551e572e 100644 --- a/webapp/channels/src/components/channel_info_rhs/header.test.tsx +++ b/webapp/channels/src/components/channel_info_rhs/header.test.tsx @@ -10,16 +10,16 @@ import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; import Header from './header'; describe('channel_info_rhs/header', () => { - test('should the current channel name', () => { + test('renders the header title', () => { renderWithContext(
{}} />, ); + expect(screen.getByText('Info')).toBeInTheDocument(); expect(screen.getByText('my channel title')).toBeInTheDocument(); }); test('should call onClose when clicking on the close icon', async () => { @@ -29,7 +29,6 @@ describe('channel_info_rhs/header', () => {
, ); @@ -45,7 +44,6 @@ describe('channel_info_rhs/header', () => {
, ); @@ -54,28 +52,4 @@ describe('channel_info_rhs/header', () => { expect(onClose).toHaveBeenCalled(); }); - test('should have archived icon when channel is archived', () => { - const {container} = renderWithContext( -
{}} - />, - ); - - expect(container.querySelector('i.icon-archive-outline')).toBeInTheDocument(); - }); - test('should not have archived icon when channel is archived', () => { - const {container} = renderWithContext( -
{}} - />, - ); - - expect(container.querySelector('i.icon-archive-outline')).not.toBeInTheDocument(); - }); }); diff --git a/webapp/channels/src/components/channel_info_rhs/header.tsx b/webapp/channels/src/components/channel_info_rhs/header.tsx index 0f09aad73b8..867e326e3a4 100644 --- a/webapp/channels/src/components/channel_info_rhs/header.tsx +++ b/webapp/channels/src/components/channel_info_rhs/header.tsx @@ -11,20 +11,15 @@ import WithTooltip from 'components/with_tooltip'; interface Props { channel: Channel; - isArchived: boolean; isMobile: boolean; onClose: () => void; } -const Icon = styled.i` - font-size:12px; -`; - const HeaderTitle = styled.span` line-height: 2.4rem; `; -const Header = ({channel, isArchived, isMobile, onClose}: Props) => { +const Header = ({channel, isMobile, onClose}: Props) => { const {formatMessage} = useIntl(); return ( @@ -50,12 +45,10 @@ const Header = ({channel, isArchived, isMobile, onClose}: Props) => { defaultMessage='Info' /> - {channel.display_name && - {isArchived && ()} {channel.display_name} } diff --git a/webapp/channels/src/components/channel_info_rhs/menu.test.tsx b/webapp/channels/src/components/channel_info_rhs/menu.test.tsx index 73e1007978d..8867c8e119d 100644 --- a/webapp/channels/src/components/channel_info_rhs/menu.test.tsx +++ b/webapp/channels/src/components/channel_info_rhs/menu.test.tsx @@ -5,16 +5,30 @@ import React from 'react'; import type {Channel, ChannelStats} from '@mattermost/types/channels'; +import {openModal} from 'actions/views/modals'; +import {canAccessChannelSettings} from 'selectors/views/channel_settings'; + import { act, renderWithContext, screen, userEvent, + fireEvent, } from 'tests/react_testing_utils'; -import Constants from 'utils/constants'; +import Constants, {ModalIdentifiers} from 'utils/constants'; + +jest.mock('selectors/views/channel_settings', () => ({ + canAccessChannelSettings: jest.fn(), +})); +jest.mock('actions/views/modals', () => ({ + openModal: jest.fn(() => ({type: 'OPEN_MODAL'})), +})); import Menu from './menu'; +const mockedCanAccessChannelSettings = canAccessChannelSettings as unknown as jest.Mock; +const mockedOpenModal = openModal as unknown as jest.Mock; + describe('channel_info_rhs/menu', () => { const defaultProps = { channel: {type: Constants.OPEN_CHANNEL} as Channel, @@ -30,6 +44,8 @@ describe('channel_info_rhs/menu', () => { }; beforeEach(() => { + mockedOpenModal.mockClear(); + mockedCanAccessChannelSettings.mockReset(); defaultProps.actions = { openNotificationSettings: jest.fn(), showChannelFiles: jest.fn(), @@ -182,4 +198,90 @@ describe('channel_info_rhs/menu', () => { const membersItem = screen.queryByText('Members'); expect(membersItem).not.toBeInTheDocument(); }); + + test('should display Channel Settings and open modal on click (non-DM/GM, not archived, permitted)', async () => { + mockedCanAccessChannelSettings.mockReturnValue(true); + const props = {...defaultProps}; + + renderWithContext( + , + ); + + await act(async () => { + props.actions.getChannelStats(); + }); + + const settingsItem = screen.getByText('Channel Settings'); + expect(settingsItem).toBeInTheDocument(); + + fireEvent.click(settingsItem); + expect(mockedOpenModal).toHaveBeenCalledWith( + expect.objectContaining({ + modalId: ModalIdentifiers.CHANNEL_SETTINGS, + }), + ); + }); + + test('should NOT display Channel Settings in DM', async () => { + mockedCanAccessChannelSettings.mockReturnValue(true); + const props = { + ...defaultProps, + channel: {type: Constants.DM_CHANNEL} as Channel, + }; + + renderWithContext( + , + ); + await act(async () => props.actions.getChannelStats()); + expect(screen.queryByText('Channel Settings')).not.toBeInTheDocument(); + }); + + test('should NOT display Channel Settings in GM', async () => { + mockedCanAccessChannelSettings.mockReturnValue(true); + const props = { + ...defaultProps, + channel: {type: Constants.GM_CHANNEL} as Channel, + }; + + renderWithContext( + , + ); + await act(async () => props.actions.getChannelStats()); + expect(screen.queryByText('Channel Settings')).not.toBeInTheDocument(); + }); + + test('should NOT display Channel Settings when archived', async () => { + mockedCanAccessChannelSettings.mockReturnValue(true); + const props = { + ...defaultProps, + isArchived: true, + }; + + renderWithContext( + , + ); + await act(async () => props.actions.getChannelStats()); + expect(screen.queryByText('Channel Settings')).not.toBeInTheDocument(); + }); + + test('should NOT display Channel Settings without permission', async () => { + mockedCanAccessChannelSettings.mockReturnValue(false); + const props = {...defaultProps}; + + renderWithContext( + , + ); + await act(async () => props.actions.getChannelStats()); + expect(screen.queryByText('Channel Settings')).not.toBeInTheDocument(); + }); }); diff --git a/webapp/channels/src/components/channel_info_rhs/menu.tsx b/webapp/channels/src/components/channel_info_rhs/menu.tsx index be850d3ee02..95ac9a38bdc 100644 --- a/webapp/channels/src/components/channel_info_rhs/menu.tsx +++ b/webapp/channels/src/components/channel_info_rhs/menu.tsx @@ -3,13 +3,20 @@ import React, {useEffect, useState} from 'react'; import {useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; import styled from 'styled-components'; import type {Channel, ChannelStats} from '@mattermost/types/channels'; +import {openModal} from 'actions/views/modals'; +import {canAccessChannelSettings} from 'selectors/views/channel_settings'; + +import ChannelSettingsModal from 'components/channel_settings_modal/channel_settings_modal'; import LoadingSpinner from 'components/widgets/loading/loading_spinner'; -import {Constants} from 'utils/constants'; +import {Constants, ModalIdentifiers} from 'utils/constants'; + +import type {GlobalState} from 'types/store'; const MenuContainer = styled.nav` display: flex; @@ -116,6 +123,7 @@ interface MenuProps { export default function Menu(props: MenuProps) { const {formatMessage} = useIntl(); + const dispatch = useDispatch(); const { channel, channelStats, @@ -128,7 +136,9 @@ export default function Menu(props: MenuProps) { const showNotificationPreferences = channel.type !== Constants.DM_CHANNEL && !isArchived; const showMembers = channel.type !== Constants.DM_CHANNEL; + const showChannelSettings = channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL && !isArchived; const fileCount = channelStats?.files_count >= 0 ? channelStats?.files_count : 0; + const canAccessSettings = useSelector((state: GlobalState) => canAccessChannelSettings(state, channel.id)); useEffect(() => { actions.getChannelStats(channel.id, true).then(() => { @@ -139,6 +149,20 @@ export default function Menu(props: MenuProps) { }; }, [channel.id]); + const openChannelSettings = () => { + dispatch( + openModal({ + modalId: ModalIdentifiers.CHANNEL_SETTINGS, + dialogType: ChannelSettingsModal, + dialogProps: { + channelId: channel.id, + focusOriginElement: 'channelInfoRHSChannelSettings', + isOpen: true, + }, + }), + ); + }; + return ( + {showChannelSettings && canAccessSettings && ( + } + text={formatMessage({ + id: 'channel_header.channel_settings', + defaultMessage: 'Channel Settings', + })} + onClick={openChannelSettings} + /> + )} {showNotificationPreferences && ( ) => Promise; +} + +type Props = { + channel: Channel; + teamName: string; + onExited: () => void; + actions: Actions; + intl: IntlShape; +} + +type State = { + show: boolean; + displayName: string; + channelUrl: string; + isSaving: boolean; + urlError: string; +} + +export class RenameChannelModal extends React.PureComponent { + constructor(props: Props) { + super(props); + this.state = { + show: true, + displayName: props.channel.display_name, + channelUrl: props.channel.name, + isSaving: false, + urlError: '', + }; + } + + onHide = () => { + this.setState({show: false}); + }; + + handleSave = async () => { + const {channel, actions: {patchChannel}} = this.props; + const {displayName, channelUrl} = this.state; + + if (!channel || !displayName?.trim()) { + return; + } + + // Validate min/max on display name + const trimmedDisplayName = displayName.trim(); + if (trimmedDisplayName.length < Constants.MIN_CHANNELNAME_LENGTH || + trimmedDisplayName.length > Constants.MAX_CHANNELNAME_LENGTH) { + return; + } + + this.setState({isSaving: true}); + const {data, error} = await patchChannel(channel.id, { + display_name: trimmedDisplayName, + name: channelUrl.trim(), + }); + this.setState({isSaving: false}); + + if (data && !error) { + this.onHide(); + + // Use the actual channel name from the response, as the server may have sanitized it + const updatedChannelName = data.name || channelUrl.trim(); + const path = `/${this.props.teamName}/channels/${updatedChannelName}`; + getHistory().push(path); + } + }; + + render() { + const {formatMessage} = this.props.intl; + return ( + + + + + + + + this.setState({displayName: name})} + onURLChange={(url) => this.setState({channelUrl: url})} + currentUrl={this.state.channelUrl} + readOnly={false} + isEditingExistingChannel={true} + onErrorStateChange={(isError, errorMsg) => this.setState({urlError: isError ? (errorMsg || '') : ''})} + urlError={this.state.urlError} + /> + + + + + + + ); + } +} + +export default injectIntl(RenameChannelModal); + diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index ea5ba8d172d..cb21d59ffd5 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3709,6 +3709,7 @@ "channel_header.unmute": "Unmute Channel", "channel_header.unmuteConversation": "Unmute", "channel_header.userHelpGuide": "Help", + "channel_info_rhs.about_area_handle": "Channel handle:", "channel_info_rhs.about_area_id": "ID:", "channel_info_rhs.about_area.add_channel_header": "Add a channel header", "channel_info_rhs.about_area.add_channel_purpose": "Add a channel purpose", @@ -3718,6 +3719,11 @@ "channel_info_rhs.about_area.channel_purpose.heading": "Channel Purpose", "channel_info_rhs.about_area.channel_purpose.line_limiter.less": "less", "channel_info_rhs.about_area.channel_purpose.line_limiter.more": "more", + "channel_info_rhs.about_area.edit_channel_header": "Edit channel header", + "channel_info_rhs.about_area.edit_channel_name": "Rename channel", + "channel_info_rhs.about_area.edit_channel_purpose": "Edit channel purpose", + "channel_info_rhs.archived.title": "This channel is archived.", + "channel_info_rhs.archived.unarchive": "Unarchive", "channel_info_rhs.edit_link": "Edit", "channel_info_rhs.header.title": "Info", "channel_info_rhs.menu.files": "Files", @@ -5698,6 +5704,8 @@ "removed_channel.someone": "Someone", "rename_category_modal.rename": "Rename", "rename_category_modal.renameCategory": "Rename Category", + "rename_channel.displayNameHolder": "Enter display name", + "rename_channel.title": "Rename Channel", "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", diff --git a/webapp/channels/src/sass/components/_modal.scss b/webapp/channels/src/sass/components/_modal.scss index 96d8e52bc92..b40483f1574 100644 --- a/webapp/channels/src/sass/components/_modal.scss +++ b/webapp/channels/src/sass/components/_modal.scss @@ -21,7 +21,7 @@ .modal-body { overflow: auto; max-height: calc(90vh - 80px); - padding: 2px 32px; + padding: 4px 32px; &.overflow--visible { overflow: visible; diff --git a/webapp/channels/src/sass/layout/_headers.scss b/webapp/channels/src/sass/layout/_headers.scss index 4c018041a51..82ca67de28d 100644 --- a/webapp/channels/src/sass/layout/_headers.scss +++ b/webapp/channels/src/sass/layout/_headers.scss @@ -943,6 +943,7 @@ .channel-header-archived-icon { position: relative; + top: -1px; margin-right: 5px; fill: var(--center-channel-color); } diff --git a/webapp/channels/src/sass/layout/_sidebar-right.scss b/webapp/channels/src/sass/layout/_sidebar-right.scss index b33f82e05f3..2af7889be01 100644 --- a/webapp/channels/src/sass/layout/_sidebar-right.scss +++ b/webapp/channels/src/sass/layout/_sidebar-right.scss @@ -129,6 +129,7 @@ font-family: Metropolis, sans-serif; font-size: 1.6rem; font-weight: 600; + line-height: 1; @include mixins.clearfix; h2 {