MM-66561 Add distinct archive icon for private channels (#34736)
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions

* MM-66561 Add distinct archive icon for private channels

Archived private channels now display an archive-lock icon instead of the standard archive icon to better indicate their original privacy level. Implemented utility functions to centralize icon selection logic across all channel list views, sidebars, headers, and suggestion providers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* MM-66561 Fix linting and TypeScript errors

Fix ESLint and TypeScript issues introduced in the archive icon implementation:
- Remove extra blank lines to comply with no-multiple-empty-lines rule
- Remove unused container variables in test files
- Fix import order to comply with import/order rule
- Remove unused React import
- Fix TypeScript type errors by using General.OPEN_CHANNEL/PRIVATE_CHANNEL from mattermost-redux/constants which preserves literal types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* MM-66561 Fix test failures for archive icon changes

Update test snapshots and fix test data issues related to the new distinct archive icons for public and private channels.

- Update snapshots for channel list components to include new channelType prop and data-testid attributes
- Fix channel_mention_provider test by preserving actual module exports in mock
- Add missing purpose field to searchable_channel_list test data
- Fix async state handling in new_channel_modal test using waitFor instead of act

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* MM-66561 Fix remaining Cypress E2E test failures for archive icons

Fix three failing Cypress tests related to archive icon changes:

1. join_archived_channel_spec.ts (MM-T1682, MM-T1683)
   - Add data-testid to archive icon in channel header
   - Update test to use findByTestId instead of CSS class selector
   - Compass icon components render as SVG, not <i> with classes

2. archived_channels_spec.js (system console tests)
   - Add "000-" prefix to private channel name/display name
   - Ensures proper alphabetical sorting on first page of results

3. long_draft_spec.js (MM-T211)
   - Fix Cypress alias timing issues in nested then() callbacks
   - Use local variable to track height changes during iteration
   - Replace cy.get('@alias').should() with direct expect() assertions

All tests now pass with the distinct archive icons for private channels.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* fix lint issue

* MM-66561 Refine archive icon styling and search results display

- Restore CSS classes on channel header icon for proper color and size
- Fix icon alignment by removing top offset in channel header context
- Replace "Archived" text with icon-only tooltip in search results
- Add context-specific styling to prevent conflicts between header and search

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* tweaks to css and move withtooltip to wrap the span

* lint fix

* lint fix

* Fix archived channel icons visual test API usage

Update test to use correct Playwright API patterns:
- Use adminClient.createChannel with pw.random.channel for channel creation
- Use adminClient.deleteChannel instead of pw.apiClient.deleteChannel
- Use pw.testBrowser.login(adminUser) instead of loginAsAdmin
- Remove channelsPage.toBeVisible check for archived channels since they lack post-create element

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Apply prettier formatting to archived channel icons test

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Fix timing issue in MM-T633 Elasticsearch webhook attachment search test

The test was intermittently failing because it searched immediately after posting the webhook, before Elasticsearch had time to index the new post. Added explicit wait for post to appear and increased indexing wait time to 3 seconds to ensure the attachment text is indexed before performing the search.

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
This commit is contained in:
Scott Bishel 2026-01-14 08:35:27 -07:00 committed by GitHub
parent cf1682a0e7
commit cc2b47bc9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1150 additions and 134 deletions

View file

@ -103,7 +103,7 @@ describe('Archived channels', () => {
function verifyViewingArchivedChannel(channel) {
// * Verify that we've switched to the correct channel and that the header contains the archived icon
cy.get('#channelHeaderTitle').should('contain', channel.display_name);
cy.get('#channelHeaderInfo .icon__archive').should('be.visible');
cy.findByTestId('channel-header-archive-icon').should('be.visible');
// * Verify that the channel is visible in the sidebar with the archived icon
cy.get(`#sidebarItem_${channel.name}`).should('be.visible').

View file

@ -65,8 +65,19 @@ describe('Incoming webhook', () => {
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
// # Post webhook and wait for attachment to render
cy.postIncomingWebhook({url: incomingWebhook.url, data: payload});
// # Verify the post appears in the channel with attachment
cy.getLastPost().within(() => {
cy.get('.attachment__body').should('be.visible').should('contain', 'Findme.');
});
// # Explicitly wait to give Elasticsearch time to index before searching
// Using a longer wait time since Elasticsearch indexing can be slow in test environments
cy.wait(TIMEOUTS.THREE_SEC);
// # Search for text in the attachment
cy.uiGetSearchContainer().click();
cy.uiGetSearchBox().
wait(TIMEOUTS.HALF_SEC).

View file

@ -14,17 +14,24 @@ import * as TIMEOUTS from '../../../../fixtures/timeouts';
describe('Archived channels', () => {
let testChannel;
let testPrivateChannel;
before(() => {
cy.apiRequireLicense();
cy.apiInitSetup({
channelPrefix: {name: '000-archive', displayName: '000 Archive Test'},
}).then(({channel}) => {
}).then(({channel, team}) => {
testChannel = channel;
// # Archive the channel
// # Archive the public channel
cy.apiDeleteChannel(testChannel.id);
// # Create and archive a private channel with a prefix to ensure proper sorting
cy.apiCreateChannel(team.id, '000-private-archive', '000 Private Archive Test', 'P').then(({channel: privateChannel}) => {
testPrivateChannel = privateChannel;
cy.apiDeleteChannel(privateChannel.id);
});
});
});
@ -83,4 +90,26 @@ describe('Archived channels', () => {
expect(channel.delete_at).to.eq(0);
});
});
it('display archive icon for public archived channels in channel list', () => {
// # Go to the channels list view
cy.visit('/admin_console/user_management/channels');
// * Verify the archived public channel is visible
cy.findByText(testChannel.display_name, {timeout: TIMEOUTS.ONE_MIN}).should('be.visible');
// * Verify the archive icon is displayed
cy.findByTestId(`${testChannel.name}-archive-icon`).should('be.visible');
});
it('display archive-lock icon for private archived channels in channel list', () => {
// # Go to the channels list view
cy.visit('/admin_console/user_management/channels');
// * Verify the archived private channel is visible
cy.findByText(testPrivateChannel.display_name, {timeout: TIMEOUTS.ONE_MIN}).should('be.visible');
// * Verify the archive icon is displayed for private channel
cy.findByTestId(`${testPrivateChannel.name}-archive-icon`).should('be.visible');
});
});

View file

@ -81,6 +81,13 @@ describe('Messaging', () => {
});
function writeLinesToPostTextBox(lines) {
let previousHeight;
// Get the initial previous height from the alias
cy.get('@previousHeight').then((height) => {
previousHeight = height;
});
Cypress._.forEach(lines, (line, i) => {
// # Add the text
cy.uiGetPostTextBox().type(line, {delay: TIMEOUTS.ONE_HUNDRED_MILLIS}).wait(TIMEOUTS.HALF_SEC);
@ -91,11 +98,14 @@ function writeLinesToPostTextBox(lines) {
// * Verify new height
cy.uiGetPostTextBox().invoke('height').then((height) => {
const currentHeight = parseInt(height, 10);
// * Verify previous height should be lower than the current height
cy.get('@previousHeight').should('be.lessThan', parseInt(height, 10));
expect(previousHeight).to.be.lessThan(currentHeight);
// # Store the current height as the previous height for the next loop
cy.wrap(parseInt(height, 10)).as('previousHeight');
previousHeight = currentHeight;
cy.wrap(currentHeight).as('previousHeight');
});
}
});

View file

@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify archived channel icons display correctly for public and private channels in various UI contexts
*/
test(
'displays archive icons for public and private channels in sidebar',
{tag: ['@visual', '@archived_channels', '@snapshots']},
async ({pw, browserName, viewport}, testInfo) => {
// # Initialize setup and create test channels
const {team, user, adminClient} = await pw.initSetup();
// # Create public and private channels
const publicChannel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'public-to-archive',
displayName: 'Public Archive Test',
type: 'O',
}),
);
const privateChannel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'private-to-archive',
displayName: 'Private Archive Test',
type: 'P',
}),
);
// # Archive both channels
await adminClient.deleteChannel(publicChannel.id);
await adminClient.deleteChannel(privateChannel.id);
// # Log in user
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Visit town square to ensure we're in a stable state
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Open browse channels modal to show archived channels
await page.keyboard.press('Control+K');
await page.waitForTimeout(500);
// # Type to search for archived channels
await page.keyboard.type('archive');
await page.waitForTimeout(500);
// # Hide dynamic content
await pw.hideDynamicChannelsContent(page);
// * Verify channel switcher shows both archived channels with correct icons
const testArgs = {page, browserName, viewport};
await pw.matchSnapshot(testInfo, testArgs);
},
);
/**
* @objective Verify archived channel icons display correctly in admin console channel list
*/
test(
'displays archive icons in admin console channel list',
{tag: ['@visual', '@archived_channels', '@admin_console', '@snapshots']},
async ({pw, browserName, viewport}, testInfo) => {
// # Initialize setup with admin user
const {team, adminUser, adminClient} = await pw.initSetup();
// # Create public and private channels
const publicChannel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'admin-public-archive',
displayName: 'Admin Public Archive',
type: 'O',
}),
);
const privateChannel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'admin-private-archive',
displayName: 'Admin Private Archive',
type: 'P',
}),
);
// # Archive both channels
await adminClient.deleteChannel(publicChannel.id);
await adminClient.deleteChannel(privateChannel.id);
// # Log in as admin
const {page} = await pw.testBrowser.login(adminUser);
// # Navigate to admin console channels list
await page.goto('/admin_console/user_management/channels');
await page.waitForTimeout(1000);
// # Wait for channel list to load
await expect(page.locator('.DataGrid')).toBeVisible({timeout: 10000});
// # Search for our test channels to bring them into view
await page.fill('[data-testid="searchInput"]', 'Admin');
await page.waitForTimeout(500);
// * Verify both archived channels are visible with correct icons
const testArgs = {page, browserName, viewport};
await pw.matchSnapshot(testInfo, testArgs);
},
);
/**
* @objective Verify archived private channel icon displays in channel header when viewing archived channel
*/
test(
'displays archive icon in channel header for archived private channel',
{tag: ['@visual', '@archived_channels', '@channel_header', '@snapshots']},
async ({pw, browserName, viewport}, testInfo) => {
// # Initialize setup
const {team, adminUser, adminClient} = await pw.initSetup();
// # Create a private channel
const privateChannel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'private-header-test',
displayName: 'Private Header Test',
type: 'P',
}),
);
// # Archive the channel
await adminClient.deleteChannel(privateChannel.id);
const {page, channelsPage} = await pw.testBrowser.login(adminUser);
// # Visit the archived channel directly
await channelsPage.goto(team.name, privateChannel.name);
// # Wait for channel header to load (archived channels don't have post-create)
await expect(page.locator('.channel-header')).toBeVisible();
// # Verify archived channel message is visible
await expect(page.locator('#channelArchivedMessage')).toBeVisible();
// # Hide dynamic content
await pw.hideDynamicChannelsContent(page);
// # Focus on channel header area for snapshot
const headerElement = page.locator('.channel-header');
await expect(headerElement).toBeVisible();
// * Verify channel header shows archive-lock icon for private archived channel
const testArgs = {page, browserName, viewport};
await pw.matchSnapshot(testInfo, testArgs);
},
);

View file

@ -274,6 +274,8 @@ func TestCreatePost(t *testing.T) {
})
t.Run("not logged in", func(t *testing.T) {
defer th.LoginBasic(t)
resp, err := client.Logout(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)

View file

@ -4,18 +4,15 @@
import React, {useState, useRef, useEffect} from 'react';
import {FormattedMessage, defineMessages, injectIntl, type WrappedComponentProps} from 'react-intl';
import {ArchiveOutlineIcon, GlobeIcon, LockOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import type {Team} from '@mattermost/types/teams';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import {isPrivateChannel} from 'mattermost-redux/utils/channel_utils';
import MagnifyingGlassSVG from 'components/common/svg_images_components/magnifying_glass_svg';
import LoadingScreen from 'components/loading_screen';
import QuickInput from 'components/quick_input';
import {isArchivedChannel} from 'utils/channel_utils';
import {getChannelIconComponent} from 'utils/channel_utils';
import Constants from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
@ -82,15 +79,9 @@ const SearchableSyncJobChannelList = (props: Props) => {
const createChannelRow = (channel: Channel) => {
const ariaLabel = `${channel.display_name}, ${channel.purpose}`.toLowerCase();
let channelTypeIcon;
if (isArchivedChannel(channel)) {
channelTypeIcon = <ArchiveOutlineIcon size={18}/>;
} else if (isPrivateChannel(channel)) {
channelTypeIcon = <LockOutlineIcon size={18}/>;
} else {
channelTypeIcon = <GlobeIcon size={18}/>;
}
const ChannelIcon = getChannelIconComponent(channel);
const channelTypeIcon = <ChannelIcon size={18}/>;
const team = props.teams[channel.team_id];

View file

@ -15,12 +15,9 @@ import DataGrid from 'components/admin_console/data_grid/data_grid';
import type {Column, Row} from 'components/admin_console/data_grid/data_grid';
import type {FilterOptions} from 'components/admin_console/filter/filter';
import TeamFilterDropdown from 'components/admin_console/filter/team_filter_dropdown';
import ArchiveIcon from 'components/widgets/icons/archive_icon';
import GlobeIcon from 'components/widgets/icons/globe_icon';
import LockIcon from 'components/widgets/icons/lock_icon';
import WithTooltip from 'components/with_tooltip';
import {isArchivedChannel} from 'utils/channel_utils';
import {getChannelIconComponent} from 'utils/channel_utils';
import {Constants} from 'utils/constants';
import './channel_list.scss';
@ -421,19 +418,13 @@ class ChannelList extends React.PureComponent<Props, State> {
].slice(startCount - 1, endCount);
return channelsToDisplay.map((channel) => {
// Determine which icon to display based on channel type
let iconToDisplay = <GlobeIcon className='channel-icon'/>;
if (channel.type === Constants.PRIVATE_CHANNEL) {
iconToDisplay = <LockIcon className='channel-icon'/>;
}
if (isArchivedChannel(channel)) {
iconToDisplay = (
<ArchiveIcon
className='channel-icon'
data-testid={`${channel.name}-archive-icon`}
/>
);
}
const ChannelIconComponent = getChannelIconComponent(channel);
const iconToDisplay = (
<ChannelIconComponent
className='channel-icon'
data-testid={`${channel.name}-archive-icon`}
/>
);
// Determine the button text and action based on the channel state
const buttonText = (

View file

@ -112,6 +112,7 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<div
className="ChannelList__nameText"
@ -258,6 +259,7 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<div
className="ChannelList__nameText"
@ -292,6 +294,7 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<div
className="ChannelList__nameText"
@ -326,6 +329,7 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<div
className="ChannelList__nameText"
@ -360,6 +364,7 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<div
className="ChannelList__nameText"
@ -394,6 +399,7 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<div
className="ChannelList__nameText"
@ -428,6 +434,7 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<div
className="ChannelList__nameText"
@ -462,6 +469,7 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<div
className="ChannelList__nameText"
@ -496,6 +504,7 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<div
className="ChannelList__nameText"
@ -530,6 +539,7 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<div
className="ChannelList__nameText"
@ -564,6 +574,7 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<div
className="ChannelList__nameText"

View file

@ -14,11 +14,8 @@ import DataGrid from 'components/admin_console/data_grid/data_grid';
import type {Column, Row} from 'components/admin_console/data_grid/data_grid';
import type {FilterOptions} from 'components/admin_console/filter/filter';
import TeamFilterDropdown from 'components/admin_console/filter/team_filter_dropdown';
import ArchiveIcon from 'components/widgets/icons/archive_icon';
import GlobeIcon from 'components/widgets/icons/globe_icon';
import LockIcon from 'components/widgets/icons/lock_icon';
import {isArchivedChannel} from 'utils/channel_utils';
import {getChannelIconComponent} from 'utils/channel_utils';
import {Constants} from 'utils/constants';
import './channel_list.scss';
@ -189,19 +186,13 @@ export default class ChannelList extends React.PureComponent<Props, State> {
}
return channelsToDisplay.map((channel) => {
let iconToDisplay = <GlobeIcon className='channel-icon'/>;
if (channel.type === Constants.PRIVATE_CHANNEL) {
iconToDisplay = <LockIcon className='channel-icon'/>;
}
if (isArchivedChannel(channel)) {
iconToDisplay = (
<ArchiveIcon
className='channel-icon'
data-testid={`${channel.name}-archive-icon`}
/>
);
}
const ChannelIconComponent = getChannelIconComponent(channel);
const iconToDisplay = (
<ChannelIconComponent
className='channel-icon'
data-testid={`${channel.name}-archive-icon`}
/>
);
return {
cells: {
id: channel.id,

View file

@ -7,7 +7,6 @@ import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import styled from 'styled-components';
import {ArchiveOutlineIcon, GlobeIcon, LockIcon} from '@mattermost/compass-icons/components';
import type IconProps from '@mattermost/compass-icons/components/props';
import {GenericModal} from '@mattermost/components';
import type {Channel, ChannelWithTeamData} from '@mattermost/types/channels';
@ -19,7 +18,7 @@ import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import SectionNotice from 'components/section_notice';
import ChannelsInput from 'components/widgets/inputs/channels_input';
import {isArchivedChannel} from 'utils/channel_utils';
import {getChannelIconComponent} from 'utils/channel_utils';
import Constants from 'utils/constants';
import type {GlobalState} from 'types/store';
@ -269,15 +268,7 @@ const ChannelLabel = ({channel, bold}: {channel: Channel; bold?: boolean}) => {
};
const ChannelIcon = ({channel, size = 16, ...otherProps}: {channel: Channel} & IconProps) => {
let Icon = GlobeIcon;
if (channel?.type === Constants.PRIVATE_CHANNEL) {
Icon = LockIcon;
}
if (isArchivedChannel(channel)) {
Icon = ArchiveOutlineIcon;
}
const Icon = getChannelIconComponent(channel);
return (
<Icon

View file

@ -10,7 +10,7 @@ import {useDispatch, useSelector} from 'react-redux';
import {useHistory, useParams, useLocation} from 'react-router-dom';
import styled from 'styled-components';
import {GlobeIcon, LockIcon, PlusIcon, ArchiveOutlineIcon} from '@mattermost/compass-icons/components';
import {PlusIcon} from '@mattermost/compass-icons/components';
import {isRemoteClusterPatch, type RemoteCluster} from '@mattermost/types/remote_clusters';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
@ -22,8 +22,7 @@ import ExternalLink from 'components/external_link';
import LoadingScreen from 'components/loading_screen';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import {isArchivedChannel} from 'utils/channel_utils';
import Constants from 'utils/constants';
import {getChannelIconComponent} from 'utils/channel_utils';
import type {GlobalState} from 'types/store';
@ -425,19 +424,11 @@ const TabsWrapper = styled.div`
const ChannelIcon = ({channelId}: {channelId: string}) => {
const channel = useSelector((state: GlobalState) => getChannel(state, channelId));
let icon = <GlobeIcon size={16}/>;
if (channel?.type === Constants.PRIVATE_CHANNEL) {
icon = <LockIcon size={16}/>;
}
if (isArchivedChannel(channel)) {
icon = <ArchiveOutlineIcon size={16}/>;
}
const IconComponent = getChannelIconComponent(channel);
return (
<ChannelIconWrapper>
{icon}
<IconComponent size={16}/>
</ChannelIconWrapper>
);
};

View file

@ -184,6 +184,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
@ -397,6 +398,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
@ -446,6 +448,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
@ -495,6 +498,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
@ -544,6 +548,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
@ -593,6 +598,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
@ -642,6 +648,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
@ -691,6 +698,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
@ -740,6 +748,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
@ -789,6 +798,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
@ -838,6 +848,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
@ -1051,6 +1062,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
>
<GlobeIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
@ -1083,3 +1095,431 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn
/>
</div>
`;
exports[`admin_console/team_channel_settings/channel/ChannelList should render correct icon for archived private channel 1`] = `
<div
className="ChannelsList"
>
<DataGrid
columns={
Array [
Object {
"field": "name",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Name"
id="admin.channel_settings.channel_list.nameHeader"
/>,
"width": 4,
},
Object {
"field": "team",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Team"
id="admin.channel_settings.channel_list.teamHeader"
/>,
"width": 1.5,
},
Object {
"field": "management",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Management"
id="admin.channel_settings.channel_list.managementHeader"
/>,
},
Object {
"field": "edit",
"fixed": true,
"name": "",
"textAlign": "right",
},
]
}
endCount={1}
filterProps={
Object {
"keys": Array [
"teams",
"channels",
"management",
],
"onFilter": [Function],
"options": Object {
"channels": Object {
"keys": Array [
"public",
"private",
"deleted",
],
"name": "Channels",
"values": Object {
"deleted": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Archived"
id="admin.channel_list.archived"
/>,
"value": false,
},
"private": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Private"
id="admin.channel_list.private"
/>,
"value": false,
},
"public": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Public"
id="admin.channel_list.public"
/>,
"value": false,
},
},
},
"management": Object {
"keys": Array [
"group_constrained",
"exclude_group_constrained",
"access_control_policy_enforced",
],
"name": "Management",
"values": Object {
"access_control_policy_enforced": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Attribute Based"
id="admin.channel_list.attributed_based"
/>,
"value": false,
},
"exclude_group_constrained": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_list.manual_invites"
/>,
"value": false,
},
"group_constrained": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Group Sync"
id="admin.channel_list.group_sync"
/>,
"value": false,
},
},
},
"teams": Object {
"keys": Array [
"team_ids",
],
"name": "Teams",
"type": Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
},
"values": Object {
"team_ids": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Teams"
id="admin.team_settings.title"
/>,
"value": Array [],
},
},
},
},
}
}
loading={false}
nextPage={[Function]}
onSearch={[Function]}
page={0}
placeholderEmpty={
<Memo(MemoizedFormattedMessage)
defaultMessage="No channels found"
id="admin.channel_settings.channel_list.no_channels_found"
/>
}
previousPage={[Function]}
rows={
Array [
Object {
"cells": Object {
"edit": <span
className="group-actions TeamList_editRow"
data-testid="DNedit"
>
<Link
to="/admin_console/user_management/channels/archived-private"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Edit"
id="admin.channel_settings.channel_row.configure"
/>
</Link>
</span>,
"id": "archived-private",
"management": <span
className="group-description adjusted row-content"
>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"
data-testid="channel-display-name"
>
<ArchiveLockOutlineIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
>
Archived Private
</span>
</span>,
"team": <span
className="group-description row-content"
>
teamDisplayName
</span>,
},
"onClick": [Function],
},
]
}
rowsContainerStyles={
Object {
"minHeight": "40px",
}
}
startCount={1}
term=""
total={1}
/>
</div>
`;
exports[`admin_console/team_channel_settings/channel/ChannelList should render correct icon for archived public channel 1`] = `
<div
className="ChannelsList"
>
<DataGrid
columns={
Array [
Object {
"field": "name",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Name"
id="admin.channel_settings.channel_list.nameHeader"
/>,
"width": 4,
},
Object {
"field": "team",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Team"
id="admin.channel_settings.channel_list.teamHeader"
/>,
"width": 1.5,
},
Object {
"field": "management",
"fixed": true,
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Management"
id="admin.channel_settings.channel_list.managementHeader"
/>,
},
Object {
"field": "edit",
"fixed": true,
"name": "",
"textAlign": "right",
},
]
}
endCount={1}
filterProps={
Object {
"keys": Array [
"teams",
"channels",
"management",
],
"onFilter": [Function],
"options": Object {
"channels": Object {
"keys": Array [
"public",
"private",
"deleted",
],
"name": "Channels",
"values": Object {
"deleted": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Archived"
id="admin.channel_list.archived"
/>,
"value": false,
},
"private": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Private"
id="admin.channel_list.private"
/>,
"value": false,
},
"public": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Public"
id="admin.channel_list.public"
/>,
"value": false,
},
},
},
"management": Object {
"keys": Array [
"group_constrained",
"exclude_group_constrained",
"access_control_policy_enforced",
],
"name": "Management",
"values": Object {
"access_control_policy_enforced": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Attribute Based"
id="admin.channel_list.attributed_based"
/>,
"value": false,
},
"exclude_group_constrained": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_list.manual_invites"
/>,
"value": false,
},
"group_constrained": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Group Sync"
id="admin.channel_list.group_sync"
/>,
"value": false,
},
},
},
"teams": Object {
"keys": Array [
"team_ids",
],
"name": "Teams",
"type": Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
},
"values": Object {
"team_ids": Object {
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Teams"
id="admin.team_settings.title"
/>,
"value": Array [],
},
},
},
},
}
}
loading={false}
nextPage={[Function]}
onSearch={[Function]}
page={0}
placeholderEmpty={
<Memo(MemoizedFormattedMessage)
defaultMessage="No channels found"
id="admin.channel_settings.channel_list.no_channels_found"
/>
}
previousPage={[Function]}
rows={
Array [
Object {
"cells": Object {
"edit": <span
className="group-actions TeamList_editRow"
data-testid="DNedit"
>
<Link
to="/admin_console/user_management/channels/archived-public"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Edit"
id="admin.channel_settings.channel_row.configure"
/>
</Link>
</span>,
"id": "archived-public",
"management": <span
className="group-description adjusted row-content"
>
<span
className="group-indicator channel-indicator channel-indicator--larger"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manual Invites"
id="admin.channel_settings.channel_row.managementMethod.manual"
/>
</span>
</span>,
"name": <span
className="group-name overflow--ellipsis row-content"
data-testid="channel-display-name"
>
<ArchiveOutlineIcon
className="channel-icon"
data-testid="DN-archive-icon"
/>
<span
className="TeamList_channelDisplayName"
>
Archived Public
</span>
</span>,
"team": <span
className="group-description row-content"
>
teamDisplayName
</span>,
},
"onClick": [Function],
},
]
}
rowsContainerStyles={
Object {
"minHeight": "40px",
}
}
startCount={1}
term=""
total={1}
/>
</div>
`;

View file

@ -4,7 +4,9 @@
import {shallow} from 'enzyme';
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import type {Channel, ChannelWithTeamData} from '@mattermost/types/channels';
import {General} from 'mattermost-redux/constants';
import {TestHelper} from 'utils/test_helper';
@ -92,4 +94,60 @@ describe('admin_console/team_channel_settings/channel/ChannelList', () => {
wrapper.setState({loading: false});
expect(wrapper).toMatchSnapshot();
});
test('should render correct icon for archived public channel', () => {
const archivedPublicChannel: ChannelWithTeamData[] = [{
...channel,
id: 'archived-public',
type: General.OPEN_CHANNEL,
display_name: 'Archived Public',
delete_at: 1234567890,
team_display_name: 'teamDisplayName',
team_name: 'teamName',
team_update_at: 1,
}];
const actions = {
getData: jest.fn().mockResolvedValue(archivedPublicChannel),
searchAllChannels: jest.fn().mockResolvedValue(archivedPublicChannel),
};
const wrapper = shallow(
<ChannelList
data={archivedPublicChannel}
total={1}
actions={actions}
/>);
wrapper.setState({loading: false});
expect(wrapper).toMatchSnapshot();
});
test('should render correct icon for archived private channel', () => {
const archivedPrivateChannel: ChannelWithTeamData[] = [{
...channel,
id: 'archived-private',
type: General.PRIVATE_CHANNEL,
display_name: 'Archived Private',
delete_at: 1234567890,
team_display_name: 'teamDisplayName',
team_name: 'teamName',
team_update_at: 1,
}];
const actions = {
getData: jest.fn().mockResolvedValue(archivedPrivateChannel),
searchAllChannels: jest.fn().mockResolvedValue(archivedPrivateChannel),
};
const wrapper = shallow(
<ChannelList
data={archivedPrivateChannel}
total={1}
actions={actions}
/>);
wrapper.setState({loading: false});
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -16,13 +16,9 @@ import type {FilterOptions} from 'components/admin_console/filter/filter';
import TeamFilterDropdown from 'components/admin_console/filter/team_filter_dropdown';
import {PAGE_SIZE} from 'components/admin_console/team_channel_settings/abstract_list';
import SharedChannelIndicator from 'components/shared_channel_indicator';
import ArchiveIcon from 'components/widgets/icons/archive_icon';
import GlobeIcon from 'components/widgets/icons/globe_icon';
import LockIcon from 'components/widgets/icons/lock_icon';
import {getHistory} from 'utils/browser_history';
import {isArchivedChannel} from 'utils/channel_utils';
import {Constants} from 'utils/constants';
import {getChannelIconComponent} from 'utils/channel_utils';
import './channel_list.scss';
@ -190,20 +186,13 @@ export default class ChannelList extends React.PureComponent<ChannelListProps, C
channelsToDisplay = channelsToDisplay.slice(startCount - 1, endCount);
return channelsToDisplay.map((channel) => {
let iconToDisplay = <GlobeIcon className='channel-icon'/>;
if (channel.type === Constants.PRIVATE_CHANNEL) {
iconToDisplay = <LockIcon className='channel-icon'/>;
}
if (isArchivedChannel(channel)) {
iconToDisplay = (
<ArchiveIcon
className='channel-icon'
data-testid={`${channel.name}-archive-icon`}
/>
);
}
const ChannelIconComponent = getChannelIconComponent(channel);
const iconToDisplay = (
<ChannelIconComponent
className='channel-icon'
data-testid={`${channel.name}-archive-icon`}
/>
);
const sharedChannelIcon = channel.shared ? (
<SharedChannelIndicator

View file

@ -12,9 +12,9 @@ import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import ProfilePicture from 'components/profile_picture';
import SharedChannelIndicator from 'components/shared_channel_indicator';
import ArchiveIcon from 'components/widgets/icons/archive_icon';
import BotTag from 'components/widgets/tag/bot_tag';
import {getArchiveIconComponent} from 'utils/channel_utils';
import {Constants} from 'utils/constants';
import ChannelHeaderTitleDirect from './channel_header_title_direct';
@ -46,7 +46,13 @@ const ChannelHeaderTitle = ({
let archivedIcon;
if (channelIsArchived) {
archivedIcon = <ArchiveIcon className='icon icon__archive icon channel-header-archived-icon svg-text-color'/>;
const ArchiveIcon = getArchiveIconComponent(channel.type);
archivedIcon = (
<ArchiveIcon
className='icon icon__archive channel-header-archived-icon svg-text-color'
data-testid='channel-header-archive-icon'
/>
);
}
let sharedIcon;

View file

@ -100,4 +100,38 @@ describe('components/ChannelHeaderMenu/MenuItems/ArchiveChannel', () => {
},
});
});
test('renders ArchiveOutlineIcon for public channel', () => {
const publicChannel = TestHelper.getChannelMock({
id: 'public_channel',
type: 'O',
display_name: 'Public Channel',
});
renderWithContext(
<WithTestMenuContext>
<ArchiveChannel channel={publicChannel}/>
</WithTestMenuContext>, initialState,
);
// Check that the component renders without error
expect(screen.getByText('Archive Channel')).toBeInTheDocument();
});
test('renders ArchiveLockOutlineIcon for private channel', () => {
const privateChannel = TestHelper.getChannelMock({
id: 'private_channel',
type: 'P',
display_name: 'Private Channel',
});
renderWithContext(
<WithTestMenuContext>
<ArchiveChannel channel={privateChannel}/>
</WithTestMenuContext>, initialState,
);
// Check that the component renders without error
expect(screen.getByText('Archive Channel')).toBeInTheDocument();
});
});

View file

@ -5,7 +5,6 @@ import React, {memo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {ArchiveOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {openModal} from 'actions/views/modals';
@ -13,6 +12,7 @@ import {openModal} from 'actions/views/modals';
import DeleteChannelModal from 'components/delete_channel_modal';
import * as Menu from 'components/menu';
import {getArchiveIconComponent} from 'utils/channel_utils';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
@ -36,10 +36,12 @@ const ArchiveChannel = ({
);
};
const ArchiveIcon = getArchiveIconComponent(channel.type);
return (
<Menu.Item
id='channelArchiveChannel'
leadingElement={<ArchiveOutlineIcon size={18}/>}
leadingElement={<ArchiveIcon size={18}/>}
onClick={handleArchiveChannel}
labels={
<FormattedMessage

View file

@ -9,7 +9,7 @@ import type {OptionProps, SingleValueProps, OnChangeValue, DropdownIndicatorProp
import AsyncSelect from 'react-select/async';
import {
ArchiveOutlineIcon, ChevronDownIcon,
ChevronDownIcon,
GlobeIcon,
LockOutlineIcon,
MessageTextOutlineIcon,
@ -29,6 +29,7 @@ import SwitchChannelProvider from 'components/suggestion/switch_channel_provider
import BotTag from 'components/widgets/tag/bot_tag';
import GuestTag from 'components/widgets/tag/guest_tag';
import {getArchiveIconComponent} from 'utils/channel_utils';
import Constants from 'utils/constants';
import * as Utils from 'utils/utils';
@ -74,7 +75,8 @@ const FormattedOption = (props: ChannelOption & {className: string; isSingleValu
};
if (channelIsArchived) {
icon = <ArchiveOutlineIcon {...iconProps}/>;
const ArchiveIcon = getArchiveIconComponent(details.type);
icon = <ArchiveIcon {...iconProps}/>;
} else if (details.type === Constants.OPEN_CHANNEL) {
icon = <GlobeIcon {...iconProps}/>;
} else if (details.type === Constants.PRIVATE_CHANNEL) {

View file

@ -399,10 +399,13 @@ describe('components/new_channel_modal', () => {
expect(createChannelButton).toBeEnabled();
// Submit
await act(async () => userEvent.click(createChannelButton));
await userEvent.click(createChannelButton);
const serverError = screen.getByText('Something went wrong. Please try again.');
expect(serverError).toBeInTheDocument();
// Wait for async state updates
await waitFor(() => {
const serverError = screen.getByText('Something went wrong. Please try again.');
expect(serverError).toBeInTheDocument();
});
expect(createChannelButton).toBeDisabled();
});

View file

@ -38,12 +38,12 @@ import PostTime from 'components/post_view/post_time';
import ReactionList from 'components/post_view/reaction_list';
import ThreadFooter from 'components/threading/channel_threads/thread_footer';
import type {Props as TimestampProps} from 'components/timestamp/timestamp';
import ArchiveIcon from 'components/widgets/icons/archive_icon';
import InfoSmallIcon from 'components/widgets/icons/info_small_icon';
import WithTooltip from 'components/with_tooltip';
import {createBurnOnReadDeleteModalHandlers} from 'hooks/useBurnOnReadDeleteModal';
import {getHistory} from 'utils/browser_history';
import {getArchiveIconComponent} from 'utils/channel_utils';
import Constants, {A11yCustomEventTypes, AppEvents, Locations, PostTypes, ModalIdentifiers} from 'utils/constants';
import type {A11yFocusEventDetail} from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
@ -709,13 +709,20 @@ function PostComponent(props: Props) {
</span>
}
{props.channelIsArchived &&
<span className='search-channel__archived'>
<ArchiveIcon className='icon icon__archive channel-header-archived-icon svg-text-color'/>
<FormattedMessage
id='search_item.channelArchived'
defaultMessage='Archived'
/>
</span>
<WithTooltip
id='channelArchivedTooltip'
title={formatMessage({
id: 'search_item.channelArchived',
defaultMessage: 'Archived',
})}
>
<span className='search-channel__archived'>
{(() => {
const ArchiveIcon = getArchiveIconComponent(props.channelType);
return <ArchiveIcon className='icon icon__archive channel-header-archived-icon svg-text-color'/>;
})()}
</span>
</WithTooltip>
}
{(Boolean(isSearchResultItem) || props.isFlaggedPosts) && Boolean(props.teamDisplayName) &&
<span className='search-team__name'>

View file

@ -4,6 +4,8 @@
import {shallow} from 'enzyme';
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import {SearchableChannelList} from 'components/searchable_channel_list';
import {type MockIntl} from 'tests/helpers/intl-test-helper';
@ -50,4 +52,54 @@ describe('components/SearchableChannelList', () => {
expect(wrapper.state('page')).toEqual(0);
});
test('should render ArchiveOutlineIcon for archived public channels', () => {
const channels = [
{
id: 'channel1',
name: 'archived-public-channel',
display_name: 'Archived Public Channel',
type: 'O',
delete_at: 1234567890,
team_id: 'team1',
purpose: '',
} as Channel,
];
const wrapper = shallow(
<SearchableChannelList
{...baseProps}
channels={channels}
/>,
);
const channelRow = wrapper.find('.more-modal__row').first();
expect(channelRow.find('ArchiveOutlineIcon')).toHaveLength(1);
expect(channelRow.find('ArchiveLockOutlineIcon')).toHaveLength(0);
});
test('should render ArchiveLockOutlineIcon for archived private channels', () => {
const channels = [
{
id: 'channel2',
name: 'archived-private-channel',
display_name: 'Archived Private Channel',
type: 'P',
delete_at: 1234567890,
team_id: 'team1',
purpose: '',
} as Channel,
];
const wrapper = shallow(
<SearchableChannelList
{...baseProps}
channels={channels}
/>,
);
const channelRow = wrapper.find('.more-modal__row').first();
expect(channelRow.find('ArchiveLockOutlineIcon')).toHaveLength(1);
expect(channelRow.find('ArchiveOutlineIcon')).toHaveLength(0);
});
});

View file

@ -9,8 +9,6 @@ import {ArchiveOutlineIcon, CheckIcon, ChevronDownIcon, GlobeIcon, LockOutlineIc
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import type {RelationOneToOne} from '@mattermost/types/utilities';
import {isPrivateChannel} from 'mattermost-redux/utils/channel_utils';
import MagnifyingGlassSVG from 'components/common/svg_images_components/magnifying_glass_svg';
import LoadingScreen from 'components/loading_screen';
import * as Menu from 'components/menu';
@ -19,7 +17,7 @@ import SharedChannelIndicator from 'components/shared_channel_indicator';
import CheckboxCheckedIcon from 'components/widgets/icons/checkbox_checked_icon';
import LoadingWrapper from 'components/widgets/loading/loading_wrapper';
import {isArchivedChannel} from 'utils/channel_utils';
import {getChannelIconComponent} from 'utils/channel_utils';
import Constants, {ModalIdentifiers} from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import * as UserAgent from 'utils/user_agent';
@ -123,15 +121,8 @@ export class SearchableChannelList extends React.PureComponent<Props, State> {
createChannelRow = (channel: Channel) => {
const ariaLabel = `${channel.display_name}, ${channel.purpose}`.toLowerCase();
let channelTypeIcon;
if (isArchivedChannel(channel)) {
channelTypeIcon = <ArchiveOutlineIcon size={18}/>;
} else if (isPrivateChannel(channel)) {
channelTypeIcon = <LockOutlineIcon size={18}/>;
} else {
channelTypeIcon = <GlobeIcon size={18}/>;
}
const ChannelIcon = getChannelIconComponent(channel);
const channelTypeIcon = <ChannelIcon size={18}/>;
let memberCount = 0;
if (this.props.channelsMemberCount?.[channel.id]) {
memberCount = this.props.channelsMemberCount[channel.id];

View file

@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import Constants from 'utils/constants';
import SidebarChannelIcon from './sidebar_channel_icon';
describe('components/sidebar/sidebar_channel/sidebar_channel_icon', () => {
const baseIcon = <i className='icon icon-globe'/>;
test('should render the provided icon when channel is not deleted', () => {
const wrapper = shallow(
<SidebarChannelIcon
isDeleted={false}
icon={baseIcon}
/>,
);
expect(wrapper.find('.icon-globe')).toHaveLength(1);
expect(wrapper.find('.icon-archive-outline')).toHaveLength(0);
expect(wrapper.find('.icon-archive-lock-outline')).toHaveLength(0);
});
test('should render archive icon for deleted public channel', () => {
const wrapper = shallow(
<SidebarChannelIcon
isDeleted={true}
icon={baseIcon}
channelType={Constants.OPEN_CHANNEL}
/>,
);
expect(wrapper.find('.icon-archive-outline')).toHaveLength(1);
expect(wrapper.find('.icon-archive-lock-outline')).toHaveLength(0);
});
test('should render archive-lock icon for deleted private channel', () => {
const wrapper = shallow(
<SidebarChannelIcon
isDeleted={true}
icon={baseIcon}
channelType={Constants.PRIVATE_CHANNEL}
/>,
);
expect(wrapper.find('.icon-archive-lock-outline')).toHaveLength(1);
expect(wrapper.find('.icon-archive-outline')).toHaveLength(0);
});
test('should render regular archive icon when channelType is not provided', () => {
const wrapper = shallow(
<SidebarChannelIcon
isDeleted={true}
icon={baseIcon}
/>,
);
expect(wrapper.find('.icon-archive-outline')).toHaveLength(1);
expect(wrapper.find('.icon-archive-lock-outline')).toHaveLength(0);
});
});

View file

@ -3,15 +3,18 @@
import React from 'react';
import {getArchiveIconClassName} from 'utils/channel_utils';
type Props = {
icon: JSX.Element | null;
isDeleted: boolean;
channelType?: string;
};
function SidebarChannelIcon({isDeleted, icon}: Props) {
function SidebarChannelIcon({isDeleted, icon, channelType}: Props) {
if (isDeleted) {
return (
<i className='icon icon-archive-outline'/>
<i className={`icon ${getArchiveIconClassName(channelType)}`}/>
);
}
return icon;

View file

@ -10,6 +10,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should fetch sh
to="http://a.fake.link"
>
<SidebarChannelIcon
channelType="O"
icon={null}
isDeleted={false}
/>
@ -97,6 +98,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn
to="http://a.fake.link"
>
<SidebarChannelIcon
channelType="O"
icon={null}
isDeleted={false}
/>
@ -179,6 +181,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn
to="http://a.fake.link"
>
<SidebarChannelIcon
channelType="O"
icon={null}
isDeleted={false}
/>
@ -261,6 +264,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn
to="http://a.fake.link"
>
<SidebarChannelIcon
channelType="O"
icon={null}
isDeleted={false}
/>
@ -347,6 +351,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn
to="http://a.fake.link"
>
<SidebarChannelIcon
channelType="O"
icon={null}
isDeleted={false}
/>
@ -429,6 +434,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should not fetc
to="http://a.fake.link"
>
<SidebarChannelIcon
channelType="O"
icon={null}
isDeleted={false}
/>
@ -521,6 +527,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should not fetc
to="http://a.fake.link"
>
<SidebarChannelIcon
channelType="O"
icon={null}
isDeleted={false}
/>

View file

@ -245,6 +245,7 @@ export class SidebarChannelLink extends React.PureComponent<Props, State> {
<SidebarChannelIcon
isDeleted={channel.delete_at !== 0}
icon={icon}
channelType={channel.type}
/>
<div
className='SidebarChannelLinkLabel_wrapper'

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
...jest.requireActual('mattermost-redux/selectors/entities/channels'),
getMyChannels: jest.fn(() => []),
getMyChannelMemberships: jest.fn(() => {}),
}));

View file

@ -14,6 +14,7 @@ import store from 'stores/redux_store';
import usePrefixedIds from 'components/common/hooks/usePrefixedIds';
import {getArchiveIconClassName} from 'utils/channel_utils';
import {Constants} from 'utils/constants';
import Provider from './provider';
@ -47,7 +48,7 @@ export const ChannelMentionSuggestion = React.forwardRef<HTMLLIElement, Suggesti
})}
>
<i
className='icon icon-archive-outline'
className={`icon ${getArchiveIconClassName(channel?.type)}`}
role='presentation'
/>
</span>

View file

@ -22,6 +22,7 @@ import store from 'stores/redux_store';
import usePrefixedIds from 'components/common/hooks/usePrefixedIds';
import {getArchiveIconClassName} from 'utils/channel_utils';
import {Constants} from 'utils/constants';
import Provider from './provider';
@ -50,7 +51,7 @@ const SearchChannelWithPermissionsSuggestion = React.forwardRef<HTMLLIElement, S
if (channelIsArchived) {
icon = (
<i
className='icon icon--no-spacing icon-archive-outline'
className={`icon icon--no-spacing ${getArchiveIconClassName(channel.type)}`}
aria-label={formatMessage({
id: 'suggestion.archived_channel',
defaultMessage: 'Archived channel',

View file

@ -59,6 +59,7 @@ import SharedChannelIndicator from 'components/shared_channel_indicator';
import BotTag from 'components/widgets/tag/bot_tag';
import GuestTag from 'components/widgets/tag/guest_tag';
import {getArchiveIconClassName} from 'utils/channel_utils';
import {Constants, StoragePrefixes} from 'utils/constants';
import {getIntl} from 'utils/i18n';
import * as Utils from 'utils/utils';
@ -195,7 +196,7 @@ export const SwitchChannelSuggestion = React.forwardRef<HTMLLIElement, Props>(({
defaultMessage: 'Archived channel',
})}
>
<i className='icon icon-archive-outline'/>
<i className={`icon ${getArchiveIconClassName(channel.type)}`}/>
</span>
);
} else if (hasDraft) {

View file

@ -5,7 +5,6 @@ import React, {memo, forwardRef, useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {ArchiveOutlineIcon} from '@mattermost/compass-icons/components';
import type {UserProfile} from '@mattermost/types/users';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
@ -14,6 +13,7 @@ import {getPost, getLimitedViews} from 'mattermost-redux/selectors/entities/post
import AdvancedCreateComment from 'components/advanced_create_comment';
import BasicSeparator from 'components/widgets/separator/basic-separator';
import {getArchiveIconComponent} from 'utils/channel_utils';
import Constants from 'utils/constants';
import type {GlobalState} from 'types/store';
@ -69,11 +69,12 @@ const CreateComment = forwardRef<HTMLDivElement, Props>(({
}
if (channelIsArchived) {
const ArchiveIcon = getArchiveIconComponent(channelType);
return (
<div className='channel-archived-warning__container'>
<BasicSeparator/>
<div className='channel-archived-warning__content'>
<ArchiveOutlineIcon
<ArchiveIcon
size={20}
color={'rgba(var(--center-channel-color-rgb), 0.75)'}
/>

View file

@ -304,8 +304,15 @@
.search-channel__archived {
flex-shrink: 0;
margin-left: 3px;
float: right;
opacity: 0.5;
opacity: 0.64;
.channel-header-archived-icon {
position: relative;
top: 3px;
margin: 0;
}
}
.search-team__name {

View file

@ -338,7 +338,7 @@
}
.channel-header-archived-icon {
opacity: 0.5;
opacity: 0.64;
}
> a {
@ -943,7 +943,12 @@
.channel-header-archived-icon {
position: relative;
top: 2px;
margin-right: 5px;
fill: var(--center-channel-color);
}
// Specific styling for archive icon in search results context
.search-channel__archived .channel-header-archived-icon {
top: 2px;
margin-left: 4px;
}

View file

@ -1,7 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ArchiveLockOutlineIcon, ArchiveOutlineIcon, GlobeIcon, LockOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import * as Utils from 'utils/channel_utils';
import Constants from 'utils/constants';
describe('Channel Utils', () => {
describe('findNextUnreadChannelId', () => {
@ -53,4 +57,119 @@ describe('Channel Utils', () => {
expect(Utils.findNextUnreadChannelId(curChannelId, allChannelIds, unreadChannelIds, -1)).toEqual(4);
});
});
describe('getArchiveIconComponent', () => {
test('should return ArchiveLockOutlineIcon for private channels', () => {
const icon = Utils.getArchiveIconComponent(Constants.PRIVATE_CHANNEL);
expect(icon).toBe(ArchiveLockOutlineIcon);
});
test('should return ArchiveOutlineIcon for public channels', () => {
const icon = Utils.getArchiveIconComponent(Constants.OPEN_CHANNEL);
expect(icon).toBe(ArchiveOutlineIcon);
});
test('should return ArchiveOutlineIcon for DM channels', () => {
const icon = Utils.getArchiveIconComponent(Constants.DM_CHANNEL);
expect(icon).toBe(ArchiveOutlineIcon);
});
test('should return ArchiveOutlineIcon for GM channels', () => {
const icon = Utils.getArchiveIconComponent(Constants.GM_CHANNEL);
expect(icon).toBe(ArchiveOutlineIcon);
});
test('should return ArchiveOutlineIcon when channelType is undefined', () => {
const icon = Utils.getArchiveIconComponent(undefined);
expect(icon).toBe(ArchiveOutlineIcon);
});
});
describe('getArchiveIconClassName', () => {
test('should return icon-archive-lock-outline for private channels', () => {
const className = Utils.getArchiveIconClassName(Constants.PRIVATE_CHANNEL);
expect(className).toBe('icon-archive-lock-outline');
});
test('should return icon-archive-outline for public channels', () => {
const className = Utils.getArchiveIconClassName(Constants.OPEN_CHANNEL);
expect(className).toBe('icon-archive-outline');
});
test('should return icon-archive-outline for DM channels', () => {
const className = Utils.getArchiveIconClassName(Constants.DM_CHANNEL);
expect(className).toBe('icon-archive-outline');
});
test('should return icon-archive-outline for GM channels', () => {
const className = Utils.getArchiveIconClassName(Constants.GM_CHANNEL);
expect(className).toBe('icon-archive-outline');
});
test('should return icon-archive-outline when channelType is undefined', () => {
const className = Utils.getArchiveIconClassName(undefined);
expect(className).toBe('icon-archive-outline');
});
});
describe('getChannelIconComponent', () => {
test('should return ArchiveLockOutlineIcon for archived private channel', () => {
const channel = {
type: Constants.PRIVATE_CHANNEL,
delete_at: 1234567890,
} as Channel;
const icon = Utils.getChannelIconComponent(channel);
expect(icon).toBe(ArchiveLockOutlineIcon);
});
test('should return ArchiveOutlineIcon for archived public channel', () => {
const channel = {
type: Constants.OPEN_CHANNEL,
delete_at: 1234567890,
} as Channel;
const icon = Utils.getChannelIconComponent(channel);
expect(icon).toBe(ArchiveOutlineIcon);
});
test('should return LockOutlineIcon for active private channel', () => {
const channel = {
type: Constants.PRIVATE_CHANNEL,
delete_at: 0,
} as Channel;
const icon = Utils.getChannelIconComponent(channel);
expect(icon).toBe(LockOutlineIcon);
});
test('should return GlobeIcon for active public channel', () => {
const channel = {
type: Constants.OPEN_CHANNEL,
delete_at: 0,
} as Channel;
const icon = Utils.getChannelIconComponent(channel);
expect(icon).toBe(GlobeIcon);
});
test('should return GlobeIcon for DM channel', () => {
const channel = {
type: Constants.DM_CHANNEL,
delete_at: 0,
} as Channel;
const icon = Utils.getChannelIconComponent(channel);
expect(icon).toBe(GlobeIcon);
});
test('should return GlobeIcon for GM channel', () => {
const channel = {
type: Constants.GM_CHANNEL,
delete_at: 0,
} as Channel;
const icon = Utils.getChannelIconComponent(channel);
expect(icon).toBe(GlobeIcon);
});
test('should return GlobeIcon when channel is undefined', () => {
const icon = Utils.getChannelIconComponent(undefined);
expect(icon).toBe(GlobeIcon);
});
});
});

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ArchiveLockOutlineIcon, ArchiveOutlineIcon, GlobeIcon, LockOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel, ChannelType} from '@mattermost/types/channels';
import type {Team} from '@mattermost/types/teams';
@ -64,6 +65,47 @@ export function isArchivedChannel(channel?: Channel) {
return Boolean(channel && channel.delete_at !== 0);
}
/**
* Returns the appropriate archive icon component based on channel type.
* Private archived channels get a lock icon, public archived channels get a standard archive icon.
*
* @param channelType - The type of the channel (e.g., Constants.PRIVATE_CHANNEL, Constants.OPEN_CHANNEL)
* @returns The appropriate icon component
*/
export function getArchiveIconComponent(channelType?: ChannelType | string) {
return channelType === Constants.PRIVATE_CHANNEL ? ArchiveLockOutlineIcon : ArchiveOutlineIcon;
}
/**
* Returns the appropriate archive icon CSS class name based on channel type.
* Private archived channels get 'icon-archive-lock-outline', public archived channels get 'icon-archive-outline'.
*
* @param channelType - The type of the channel (e.g., Constants.PRIVATE_CHANNEL, Constants.OPEN_CHANNEL)
* @returns The appropriate icon class name
*/
export function getArchiveIconClassName(channelType?: ChannelType | string): string {
return channelType === Constants.PRIVATE_CHANNEL ? 'icon-archive-lock-outline' : 'icon-archive-outline';
}
/**
* Returns the appropriate channel icon component based on channel state and type.
* Handles archived channels (with lock for private), private channels, and public channels.
*
* @param channel - The channel object
* @returns The appropriate icon component (ArchiveLockOutlineIcon, ArchiveOutlineIcon, LockOutlineIcon, or GlobeIcon)
*/
export function getChannelIconComponent(channel?: Channel) {
if (isArchivedChannel(channel)) {
return getArchiveIconComponent(channel?.type);
}
if (channel?.type === Constants.PRIVATE_CHANNEL) {
return LockOutlineIcon;
}
return GlobeIcon;
}
type JoinPrivateChannelPromptResult = {
data: {
join: boolean;