[MM-66862] Channel Info RHS: add ability to rename and open channel settings (#34708)

* Channel Info RHS: add rename-from-info and settings access

Add channel name editable area with pencil hover and wire to a lightweight Rename Channel modal; add Channel Settings item to RHS menu with permission checks; ensure navigation after rename uses relative path to avoid 404.

* linter changes

* add padding so field labels don't get cut off

* fixes for keyboard accessibility and tooltips

* don't show channel settings for DMs and GMs

* chore(i18n): run extract to reorder new keys and fix CI

Re-extracted webapp i18n to place newly added keys (editable tooltips and rename modal) in canonical order expected by translation tooling.

* use generic_btn.cancel/save for rename modal buttons

* chore(i18n): remove unused rename_channel.cancel/save keys

* updated tests to account for new elements in the info rhs

* add cypress test for new rhs info function

* fix linting issues

* fixed tests

* linter fixes

* tweak position of edit button

* style tweaks, remove subtitle from info rhs head (redundant now), update archived state

* added 'unarchive' button to archived notice, updated translations

* fixed tests that I broke in channel info header

* add url name to channel info view

* update to 'channel handle' instead of url name'

* change order of channel handle

* add copy button

* Update about_area_channel.test.tsx

* fixed test and brought back channel subtitle in header for consistency

* fixed header test

* make channel info rhs scrollable

* fix merge issue

* Fix lint

---------

Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
This commit is contained in:
Matthew Birtch 2026-02-02 12:34:32 -05:00 committed by GitHub
parent 4049300129
commit 95ba2db4f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 647 additions and 99 deletions

View file

@ -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);
});
}

View file

@ -40,6 +40,7 @@ interface Props {
gmUsers?: UserProfile[];
canEditChannelProperties: boolean;
actions: {
editChannelName: () => void;
editChannelPurpose: () => void;
editChannelHeader: () => void;
};

View file

@ -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(
<AboutAreaChannel
{...props}
/>,
initialState,
);
const editButtons = screen.getAllByLabelText('Edit');
fireEvent.click(editButtons[0]);
expect(props.actions.editChannelName).toHaveBeenCalled();
});
});

View file

@ -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 (
<>
<ChannelName>
<EditableArea
editable={canEditChannelProperties}
content={<div>{channel.display_name}</div>}
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'})}
/>
</ChannelName>
{(channel.purpose || canEditChannelProperties) && (
<ChannelPurpose>
<ChannelDescriptionHeading>
@ -74,6 +116,7 @@ const AboutAreaChannel = ({channel, canEditChannelProperties, actions}: Props) =
</LineLimiter>
)}
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'})}
/>
</ChannelPurpose>
@ -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'})}
/>
</ChannelHeader>
)}
<ChannelId>
{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}
<SmallCopyButton
content={channel.name}
isForText={true}
/>
</ChannelId>
<ChannelId>
{formatMessage({id: 'channel_info_rhs.about_area_id', defaultMessage: 'ID:'})} {channel.id}
<SmallCopyButton
content={channel.id}
isForText={true}
/>
</ChannelId>
</>
);
};

View file

@ -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'})}
/>
</ChannelHeader>

View file

@ -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'})}
/>
</ChannelHeader>

View file

@ -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(
<ChannelInfoRHS
{...props}
/>,
);
// 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',
}),
}),
);
});
});

View file

@ -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 = ({
>
<Header
channel={channel}
isArchived={isArchived}
isMobile={isMobile}
onClose={actions.closeRightHandSide}
/>
<Scrollbars
color='--center-channel-color-rgb'
>
<TopButtons
channelType={channel.type}
channelURL={channelURL}
isFavorite={isFavorite}
isMuted={isMuted}
isInvitingPeople={isInvitingPeople}
canAddPeople={canManageMembers}
actions={{toggleFavorite, toggleMute, addPeople}}
/>
<AboutArea
channel={channel}
dmUser={dmUser}
gmUsers={gmUsers}
canEditChannelProperties={canEditChannelProperties}
actions={{
editChannelHeader,
editChannelPurpose,
}}
/>
<Divider/>
<Menu
channel={channel}
channelStats={channelStats}
isArchived={isArchived}
actions={{
openNotificationSettings,
showChannelFiles: actions.showChannelFiles,
showPinnedPosts: actions.showPinnedPosts,
showChannelMembers: actions.showChannelMembers,
getChannelStats: actions.getChannelStats,
}}
/>
<Container>
{isArchived && (
<ArchivedNoticeContainer className='sectionNoticeContainer warning'>
<ArchivedNotice className='sectionNoticeContent'>
<i className='icon icon-archive-outline sectionNoticeIcon'/>
<div className='sectionNoticeBody'>
<h4 className='sectionNoticeTitle'>
<FormattedMessage
id='channel_info_rhs.archived.title'
defaultMessage='This channel is archived.'
/>
{channel.name !== Constants.DEFAULT_CHANNEL && (
<ChannelPermissionGate
channelId={channel.id}
teamId={channel.team_id}
permissions={[Permissions.MANAGE_TEAM]}
>
<button
type='button'
className='sectionNoticeButton btn btn-link'
onClick={() => {
openUnarchiveChannel();
}}
>
<FormattedMessage
id='channel_info_rhs.archived.unarchive'
defaultMessage='Unarchive'
/>
</button>
</ChannelPermissionGate>
)}
</h4>
</div>
</ArchivedNotice>
</ArchivedNoticeContainer>
)}
<TopButtons
channelType={channel.type}
channelURL={channelURL}
isFavorite={isFavorite}
isMuted={isMuted}
isInvitingPeople={isInvitingPeople}
isArchived={isArchived}
canAddPeople={!isArchived && canManageMembers}
actions={{toggleFavorite, toggleMute, addPeople}}
/>
<AboutArea
channel={channel}
dmUser={dmUser}
gmUsers={gmUsers}
canEditChannelProperties={canEditChannelProperties}
actions={{
editChannelName,
editChannelHeader,
editChannelPurpose,
}}
/>
<Divider/>
<Menu
channel={channel}
channelStats={channelStats}
isArchived={isArchived}
actions={{
openNotificationSettings,
showChannelFiles: actions.showChannelFiles,
showPinnedPosts: actions.showPinnedPosts,
showChannelMembers: actions.showChannelMembers,
getChannelStats: actions.getChannelStats,
}}
/>
</Container>
</Scrollbars>
</div>
);

View file

@ -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
</div>
<div className='EditableArea__edit'>
{allowEditArea ? (
<EditButton
onClick={onEdit}
aria-label={formatMessage({id: 'channel_info_rhs.edit_link', defaultMessage: 'Edit'})}
>
<i className='icon icon-pencil-outline'/>
</EditButton>
<WithTooltip title={editTooltip || formatMessage({id: 'channel_info_rhs.edit_link', defaultMessage: 'Edit'})}>
<EditButton
onClick={onEdit}
aria-label={formatMessage({id: 'channel_info_rhs.edit_link', defaultMessage: 'Edit'})}
>
<i className='icon icon-pencil-outline'/>
</EditButton>
</WithTooltip>
) : ''}
</div>
</div>
@ -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;
}
`;

View file

@ -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(
<Header
channel={{display_name: 'my channel title'} as Channel}
isMobile={false}
isArchived={false}
onClose={() => {}}
/>,
);
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', () => {
<Header
channel={{display_name: 'my channel title'} as Channel}
isMobile={false}
isArchived={false}
onClose={onClose}
/>,
);
@ -45,7 +44,6 @@ describe('channel_info_rhs/header', () => {
<Header
channel={{display_name: 'my channel title'} as Channel}
isMobile={true}
isArchived={false}
onClose={onClose}
/>,
);
@ -54,28 +52,4 @@ describe('channel_info_rhs/header', () => {
expect(onClose).toHaveBeenCalled();
});
test('should have archived icon when channel is archived', () => {
const {container} = renderWithContext(
<Header
channel={{display_name: 'my channel title'} as Channel}
isMobile={false}
isArchived={true}
onClose={() => {}}
/>,
);
expect(container.querySelector('i.icon-archive-outline')).toBeInTheDocument();
});
test('should not have archived icon when channel is archived', () => {
const {container} = renderWithContext(
<Header
channel={{display_name: 'my channel title'} as Channel}
isMobile={false}
isArchived={false}
onClose={() => {}}
/>,
);
expect(container.querySelector('i.icon-archive-outline')).not.toBeInTheDocument();
});
});

View file

@ -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'
/>
</HeaderTitle>
{channel.display_name &&
<span
className='style--none sidebar--right__title__subtitle'
>
{isArchived && (<Icon className='icon icon-archive-outline'/>)}
{channel.display_name}
</span>
}

View file

@ -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(
<Menu
{...props}
/>,
);
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(
<Menu
{...props}
/>,
);
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(
<Menu
{...props}
/>,
);
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(
<Menu
{...props}
/>,
);
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(
<Menu
{...props}
/>,
);
await act(async () => props.actions.getChannelStats());
expect(screen.queryByText('Channel Settings')).not.toBeInTheDocument();
});
});

View file

@ -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 (
<MenuContainer
className={className}
@ -148,6 +172,17 @@ export default function Menu(props: MenuProps) {
defaultMessage: 'Channel Info Actions',
})}
>
{showChannelSettings && canAccessSettings && (
<MenuItem
id='channelInfoRHSChannelSettings'
icon={<i className='icon icon-cog-outline'/>}
text={formatMessage({
id: 'channel_header.channel_settings',
defaultMessage: 'Channel Settings',
})}
onClick={openChannelSettings}
/>
)}
{showNotificationPreferences && (
<MenuItem
id='channelInfoRHSNotificationSettings'

View file

@ -83,6 +83,7 @@ export interface Props {
isFavorite: boolean;
isMuted: boolean;
isInvitingPeople: boolean;
isArchived?: boolean;
canAddPeople: boolean;
@ -99,6 +100,7 @@ export default function TopButtons({
isFavorite,
isMuted,
isInvitingPeople,
isArchived = false,
canAddPeople: propsCanAddPeople,
actions,
}: Props) {
@ -109,7 +111,7 @@ export default function TopButtons({
successCopyTimeout: 1000,
});
const canAddPeople = ([Constants.OPEN_CHANNEL, Constants.PRIVATE_CHANNEL].includes(channelType) && propsCanAddPeople) || channelType === Constants.GM_CHANNEL;
const canAddPeople = !isArchived && (([Constants.OPEN_CHANNEL, Constants.PRIVATE_CHANNEL].includes(channelType) && propsCanAddPeople) || channelType === Constants.GM_CHANNEL);
const canCopyLink = [Constants.OPEN_CHANNEL, Constants.PRIVATE_CHANNEL].includes(channelType);

View file

@ -0,0 +1,25 @@
// 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 {patchChannel} from 'mattermost-redux/actions/channels';
import RenameChannelModal from './rename_channel_modal';
function mapStateToProps() {
return {};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
patchChannel,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(RenameChannelModal);

View file

@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Modal} from 'react-bootstrap';
import {FormattedMessage, injectIntl} from 'react-intl';
import type {IntlShape} from 'react-intl';
import type {Channel} from '@mattermost/types/channels';
import type {ActionResult} from 'mattermost-redux/types/actions';
import ChannelNameFormField from 'components/channel_name_form_field/channel_name_form_field';
import {getHistory} from 'utils/browser_history';
import Constants from 'utils/constants';
type Actions = {
patchChannel: (channelId: string, patch: Partial<Channel>) => Promise<ActionResult>;
}
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<Props, State> {
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 (
<Modal
dialogClassName='a11y__modal'
show={this.state.show}
onHide={this.onHide}
onExited={this.props.onExited}
role='dialog'
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>
<Modal.Body>
<ChannelNameFormField
value={this.state.displayName}
name='rename-channel'
placeholder={formatMessage({
id: 'rename_channel.displayNameHolder',
defaultMessage: 'Enter display name',
})}
onDisplayNameChange={(name) => 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}
/>
</Modal.Body>
<Modal.Footer>
<button
type='button'
className='btn btn-tertiary cancel-button'
onClick={this.onHide}
>
<FormattedMessage
id='generic_btn.cancel'
defaultMessage='Cancel'
/>
</button>
<button
type='button'
className='btn btn-primary'
disabled={this.state.isSaving}
onClick={this.handleSave}
>
<FormattedMessage
id='generic_btn.save'
defaultMessage='Save'
/>
</button>
</Modal.Footer>
</Modal>
);
}
}
export default injectIntl(RenameChannelModal);

View file

@ -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",

View file

@ -21,7 +21,7 @@
.modal-body {
overflow: auto;
max-height: calc(90vh - 80px);
padding: 2px 32px;
padding: 4px 32px;
&.overflow--visible {
overflow: visible;

View file

@ -943,6 +943,7 @@
.channel-header-archived-icon {
position: relative;
top: -1px;
margin-right: 5px;
fill: var(--center-channel-color);
}

View file

@ -129,6 +129,7 @@
font-family: Metropolis, sans-serif;
font-size: 1.6rem;
font-weight: 600;
line-height: 1;
@include mixins.clearfix;
h2 {