From cc2b47bc9bc15194e851e28d4857756b17a04d95 Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Wed, 14 Jan 2026 08:35:27 -0700 Subject: [PATCH] MM-66561 Add distinct archive icon for private channels (#34736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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 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) * 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) * 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) * Apply prettier formatting to archived channel icons test Co-Authored-By: Claude Sonnet 4.5 (1M context) * 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) --------- Co-authored-by: Claude Sonnet 4.5 (1M context) Co-authored-by: Matthew Birtch --- .../join_archived_channel_spec.ts | 2 +- .../integrations/incoming_webhook_spec.ts | 11 + .../system_console/archived_channels_spec.js | 33 +- .../channels/messaging/long_draft_spec.js | 14 +- .../channels/archived_channel_icons.spec.ts | 161 +++++++ server/channels/api4/post_test.go | 2 + .../searchable_sync_job_channel_list.tsx | 15 +- .../channel_list/channel_list.tsx | 25 +- .../__snapshots__/channel_list.test.tsx.snap | 11 + .../channel_list/channel_list.tsx | 25 +- .../modals/shared_channels_add_modal.tsx | 13 +- .../secure_connection_detail.tsx | 17 +- .../__snapshots__/channel_list.test.tsx.snap | 440 ++++++++++++++++++ .../channel/list/channel_list.test.tsx | 60 ++- .../channel/list/channel_list.tsx | 27 +- .../channel_header/channel_header_title.tsx | 10 +- .../menu_items/archive_channel.test.tsx | 34 ++ .../menu_items/archive_channel.tsx | 6 +- .../forward_post_channel_select.tsx | 6 +- .../new_channel_modal.test.tsx | 9 +- .../src/components/post/post_component.tsx | 23 +- .../searchable_channel_list.test.tsx | 52 +++ .../components/searchable_channel_list.tsx | 15 +- .../sidebar_channel_icon.test.tsx | 64 +++ .../sidebar_channel_icon.tsx | 7 +- .../sidebar_channel_link.test.tsx.snap | 7 + .../sidebar_channel_link.tsx | 1 + .../channel_mention_provider.test.tsx | 1 + .../suggestion/channel_mention_provider.tsx | 3 +- ...arch_channel_with_permissions_provider.tsx | 3 +- .../suggestion/switch_channel_provider.tsx | 3 +- .../create_comment.tsx | 5 +- .../channels/src/sass/components/_search.scss | 9 +- webapp/channels/src/sass/layout/_headers.scss | 9 +- .../channels/src/utils/channel_utils.test.ts | 119 +++++ webapp/channels/src/utils/channel_utils.tsx | 42 ++ 36 files changed, 1150 insertions(+), 134 deletions(-) create mode 100644 e2e-tests/playwright/specs/visual/channels/archived_channel_icons.spec.ts create mode 100644 webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_icon/sidebar_channel_icon.test.tsx diff --git a/e2e-tests/cypress/tests/integration/channels/archived_channel/join_archived_channel_spec.ts b/e2e-tests/cypress/tests/integration/channels/archived_channel/join_archived_channel_spec.ts index aa367eaac35..5a841df5a18 100644 --- a/e2e-tests/cypress/tests/integration/channels/archived_channel/join_archived_channel_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/archived_channel/join_archived_channel_spec.ts @@ -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'). diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/integrations/incoming_webhook_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/integrations/incoming_webhook_spec.ts index 4d079673dad..2438b67c9f3 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/integrations/incoming_webhook_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/integrations/incoming_webhook_spec.ts @@ -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). diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/archived_channels_spec.js b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/archived_channels_spec.js index 33862500693..4e2645c3a21 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/archived_channels_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/archived_channels_spec.js @@ -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'); + }); }); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/long_draft_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/long_draft_spec.js index 544fccad1ba..30f0ecf2e29 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/long_draft_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/long_draft_spec.js @@ -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'); }); } }); diff --git a/e2e-tests/playwright/specs/visual/channels/archived_channel_icons.spec.ts b/e2e-tests/playwright/specs/visual/channels/archived_channel_icons.spec.ts new file mode 100644 index 00000000000..13e17eff948 --- /dev/null +++ b/e2e-tests/playwright/specs/visual/channels/archived_channel_icons.spec.ts @@ -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); + }, +); diff --git a/server/channels/api4/post_test.go b/server/channels/api4/post_test.go index 8cb265b8fff..afb2acac375 100644 --- a/server/channels/api4/post_test.go +++ b/server/channels/api4/post_test.go @@ -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) diff --git a/webapp/channels/src/components/admin_console/access_control/modals/job_details/searchable_sync_job_channel_list.tsx b/webapp/channels/src/components/admin_console/access_control/modals/job_details/searchable_sync_job_channel_list.tsx index 3469382aecb..1dd95b49e62 100644 --- a/webapp/channels/src/components/admin_console/access_control/modals/job_details/searchable_sync_job_channel_list.tsx +++ b/webapp/channels/src/components/admin_console/access_control/modals/job_details/searchable_sync_job_channel_list.tsx @@ -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 = ; - } else if (isPrivateChannel(channel)) { - channelTypeIcon = ; - } else { - channelTypeIcon = ; - } + const ChannelIcon = getChannelIconComponent(channel); + const channelTypeIcon = ; const team = props.teams[channel.team_id]; diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.tsx b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.tsx index 044c8cae2e6..955f494c5ea 100644 --- a/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.tsx +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.tsx @@ -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 { ].slice(startCount - 1, endCount); return channelsToDisplay.map((channel) => { - // Determine which icon to display based on channel type - let iconToDisplay = ; - if (channel.type === Constants.PRIVATE_CHANNEL) { - iconToDisplay = ; - } - if (isArchivedChannel(channel)) { - iconToDisplay = ( - - ); - } + const ChannelIconComponent = getChannelIconComponent(channel); + const iconToDisplay = ( + + ); // Determine the button text and action based on the channel state const buttonText = ( diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/channel_list/__snapshots__/channel_list.test.tsx.snap b/webapp/channels/src/components/admin_console/data_retention_settings/channel_list/__snapshots__/channel_list.test.tsx.snap index 4cb94e1db87..99ad73b959a 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/channel_list/__snapshots__/channel_list.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/data_retention_settings/channel_list/__snapshots__/channel_list.test.tsx.snap @@ -112,6 +112,7 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma >
{ } return channelsToDisplay.map((channel) => { - let iconToDisplay = ; - - if (channel.type === Constants.PRIVATE_CHANNEL) { - iconToDisplay = ; - } - if (isArchivedChannel(channel)) { - iconToDisplay = ( - - ); - } + const ChannelIconComponent = getChannelIconComponent(channel); + const iconToDisplay = ( + + ); return { cells: { id: channel.id, diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.tsx index 503727db1cc..c525530355f 100644 --- a/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.tsx +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.tsx @@ -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 ( { const channel = useSelector((state: GlobalState) => getChannel(state, channelId)); - let icon = ; - - if (channel?.type === Constants.PRIVATE_CHANNEL) { - icon = ; - } - - if (isArchivedChannel(channel)) { - icon = ; - } + const IconComponent = getChannelIconComponent(channel); return ( - {icon} + ); }; diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/__snapshots__/channel_list.test.tsx.snap b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/__snapshots__/channel_list.test.tsx.snap index 00b799854ff..8f0c4f10f70 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/__snapshots__/channel_list.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/__snapshots__/channel_list.test.tsx.snap @@ -184,6 +184,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn >
`; + +exports[`admin_console/team_channel_settings/channel/ChannelList should render correct icon for archived private channel 1`] = ` +
+ , + "width": 4, + }, + Object { + "field": "team", + "fixed": true, + "name": , + "width": 1.5, + }, + Object { + "field": "management", + "fixed": true, + "name": , + }, + 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": , + "value": false, + }, + "private": Object { + "name": , + "value": false, + }, + "public": Object { + "name": , + "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": , + "value": false, + }, + "exclude_group_constrained": Object { + "name": , + "value": false, + }, + "group_constrained": Object { + "name": , + "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": , + "value": Array [], + }, + }, + }, + }, + } + } + loading={false} + nextPage={[Function]} + onSearch={[Function]} + page={0} + placeholderEmpty={ + + } + previousPage={[Function]} + rows={ + Array [ + Object { + "cells": Object { + "edit": + + + + , + "id": "archived-private", + "management": + + + + , + "name": + + + Archived Private + + , + "team": + teamDisplayName + , + }, + "onClick": [Function], + }, + ] + } + rowsContainerStyles={ + Object { + "minHeight": "40px", + } + } + startCount={1} + term="" + total={1} + /> +
+`; + +exports[`admin_console/team_channel_settings/channel/ChannelList should render correct icon for archived public channel 1`] = ` +
+ , + "width": 4, + }, + Object { + "field": "team", + "fixed": true, + "name": , + "width": 1.5, + }, + Object { + "field": "management", + "fixed": true, + "name": , + }, + 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": , + "value": false, + }, + "private": Object { + "name": , + "value": false, + }, + "public": Object { + "name": , + "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": , + "value": false, + }, + "exclude_group_constrained": Object { + "name": , + "value": false, + }, + "group_constrained": Object { + "name": , + "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": , + "value": Array [], + }, + }, + }, + }, + } + } + loading={false} + nextPage={[Function]} + onSearch={[Function]} + page={0} + placeholderEmpty={ + + } + previousPage={[Function]} + rows={ + Array [ + Object { + "cells": Object { + "edit": + + + + , + "id": "archived-public", + "management": + + + + , + "name": + + + Archived Public + + , + "team": + teamDisplayName + , + }, + "onClick": [Function], + }, + ] + } + rowsContainerStyles={ + Object { + "minHeight": "40px", + } + } + startCount={1} + term="" + total={1} + /> +
+`; diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.test.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.test.tsx index 8a7752ca56c..66bead1e418 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.test.tsx +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.test.tsx @@ -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( + ); + + 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( + ); + + wrapper.setState({loading: false}); + expect(wrapper).toMatchSnapshot(); + }); }); diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.tsx index 3d690a9ba3f..1bbff658894 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.tsx +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.tsx @@ -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 { - let iconToDisplay = ; - - if (channel.type === Constants.PRIVATE_CHANNEL) { - iconToDisplay = ; - } - - if (isArchivedChannel(channel)) { - iconToDisplay = ( - - ); - } + const ChannelIconComponent = getChannelIconComponent(channel); + const iconToDisplay = ( + + ); const sharedChannelIcon = channel.shared ? ( ; + const ArchiveIcon = getArchiveIconComponent(channel.type); + archivedIcon = ( + + ); } let sharedIcon; diff --git a/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.test.tsx b/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.test.tsx index 061b6abfb00..dc8a757ac16 100644 --- a/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.test.tsx +++ b/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.test.tsx @@ -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( + + + , 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( + + + , initialState, + ); + + // Check that the component renders without error + expect(screen.getByText('Archive Channel')).toBeInTheDocument(); + }); }); diff --git a/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.tsx b/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.tsx index 89d96a964fb..11f81659abd 100644 --- a/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.tsx +++ b/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.tsx @@ -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 ( } + leadingElement={} onClick={handleArchiveChannel} labels={ ; + const ArchiveIcon = getArchiveIconComponent(details.type); + icon = ; } else if (details.type === Constants.OPEN_CHANNEL) { icon = ; } else if (details.type === Constants.PRIVATE_CHANNEL) { diff --git a/webapp/channels/src/components/new_channel_modal/new_channel_modal.test.tsx b/webapp/channels/src/components/new_channel_modal/new_channel_modal.test.tsx index c77de51d62d..59eb1bcaeb6 100644 --- a/webapp/channels/src/components/new_channel_modal/new_channel_modal.test.tsx +++ b/webapp/channels/src/components/new_channel_modal/new_channel_modal.test.tsx @@ -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(); }); diff --git a/webapp/channels/src/components/post/post_component.tsx b/webapp/channels/src/components/post/post_component.tsx index e6157021313..4df3741971b 100644 --- a/webapp/channels/src/components/post/post_component.tsx +++ b/webapp/channels/src/components/post/post_component.tsx @@ -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) { } {props.channelIsArchived && - - - - + + + {(() => { + const ArchiveIcon = getArchiveIconComponent(props.channelType); + return ; + })()} + + } {(Boolean(isSearchResultItem) || props.isFlaggedPosts) && Boolean(props.teamDisplayName) && diff --git a/webapp/channels/src/components/searchable_channel_list.test.tsx b/webapp/channels/src/components/searchable_channel_list.test.tsx index a1c9e3635a5..0f70f7f357b 100644 --- a/webapp/channels/src/components/searchable_channel_list.test.tsx +++ b/webapp/channels/src/components/searchable_channel_list.test.tsx @@ -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( + , + ); + + 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( + , + ); + + const channelRow = wrapper.find('.more-modal__row').first(); + expect(channelRow.find('ArchiveLockOutlineIcon')).toHaveLength(1); + expect(channelRow.find('ArchiveOutlineIcon')).toHaveLength(0); + }); }); diff --git a/webapp/channels/src/components/searchable_channel_list.tsx b/webapp/channels/src/components/searchable_channel_list.tsx index e348af02114..6561337d5c9 100644 --- a/webapp/channels/src/components/searchable_channel_list.tsx +++ b/webapp/channels/src/components/searchable_channel_list.tsx @@ -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 { createChannelRow = (channel: Channel) => { const ariaLabel = `${channel.display_name}, ${channel.purpose}`.toLowerCase(); - let channelTypeIcon; - - if (isArchivedChannel(channel)) { - channelTypeIcon = ; - } else if (isPrivateChannel(channel)) { - channelTypeIcon = ; - } else { - channelTypeIcon = ; - } + const ChannelIcon = getChannelIconComponent(channel); + const channelTypeIcon = ; let memberCount = 0; if (this.props.channelsMemberCount?.[channel.id]) { memberCount = this.props.channelsMemberCount[channel.id]; diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_icon/sidebar_channel_icon.test.tsx b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_icon/sidebar_channel_icon.test.tsx new file mode 100644 index 00000000000..a285ea95e78 --- /dev/null +++ b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_icon/sidebar_channel_icon.test.tsx @@ -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 = ; + + test('should render the provided icon when channel is not deleted', () => { + const wrapper = shallow( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(wrapper.find('.icon-archive-outline')).toHaveLength(1); + expect(wrapper.find('.icon-archive-lock-outline')).toHaveLength(0); + }); +}); diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_icon/sidebar_channel_icon.tsx b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_icon/sidebar_channel_icon.tsx index bc17c006c64..c54cf85aaba 100644 --- a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_icon/sidebar_channel_icon.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_icon/sidebar_channel_icon.tsx @@ -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 ( - + ); } return icon; diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/__snapshots__/sidebar_channel_link.test.tsx.snap b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/__snapshots__/sidebar_channel_link.test.tsx.snap index 98ae00d9068..8ae63b38360 100644 --- a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/__snapshots__/sidebar_channel_link.test.tsx.snap +++ b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/__snapshots__/sidebar_channel_link.test.tsx.snap @@ -10,6 +10,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should fetch sh to="http://a.fake.link" > @@ -97,6 +98,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn to="http://a.fake.link" > @@ -179,6 +181,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn to="http://a.fake.link" > @@ -261,6 +264,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn to="http://a.fake.link" > @@ -347,6 +351,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should match sn to="http://a.fake.link" > @@ -429,6 +434,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should not fetc to="http://a.fake.link" > @@ -521,6 +527,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_channel_link should not fetc to="http://a.fake.link" > diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.tsx b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.tsx index 6b67f23b74a..886487e7500 100644 --- a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_link/sidebar_channel_link.tsx @@ -245,6 +245,7 @@ export class SidebarChannelLink extends React.PureComponent {
({ + ...jest.requireActual('mattermost-redux/selectors/entities/channels'), getMyChannels: jest.fn(() => []), getMyChannelMemberships: jest.fn(() => {}), })); diff --git a/webapp/channels/src/components/suggestion/channel_mention_provider.tsx b/webapp/channels/src/components/suggestion/channel_mention_provider.tsx index 48e1592b161..3c099b8acbd 100644 --- a/webapp/channels/src/components/suggestion/channel_mention_provider.tsx +++ b/webapp/channels/src/components/suggestion/channel_mention_provider.tsx @@ -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 diff --git a/webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.tsx b/webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.tsx index 01bc0c302c6..4fbfdcb3fdf 100644 --- a/webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.tsx +++ b/webapp/channels/src/components/suggestion/search_channel_with_permissions_provider.tsx @@ -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(({ defaultMessage: 'Archived channel', })} > - + ); } else if (hasDraft) { diff --git a/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx b/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx index 16c4571fa6a..dee6ebc2b71 100644 --- a/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx +++ b/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx @@ -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(({ } if (channelIsArchived) { + const ArchiveIcon = getArchiveIconComponent(channelType); return (
- diff --git a/webapp/channels/src/sass/components/_search.scss b/webapp/channels/src/sass/components/_search.scss index db9ef9e76aa..17f3d72ea1f 100644 --- a/webapp/channels/src/sass/components/_search.scss +++ b/webapp/channels/src/sass/components/_search.scss @@ -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 { diff --git a/webapp/channels/src/sass/layout/_headers.scss b/webapp/channels/src/sass/layout/_headers.scss index 965ebbeb19c..4c018041a51 100644 --- a/webapp/channels/src/sass/layout/_headers.scss +++ b/webapp/channels/src/sass/layout/_headers.scss @@ -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; +} diff --git a/webapp/channels/src/utils/channel_utils.test.ts b/webapp/channels/src/utils/channel_utils.test.ts index 6cf9518e8cf..a857e3fad6b 100644 --- a/webapp/channels/src/utils/channel_utils.test.ts +++ b/webapp/channels/src/utils/channel_utils.test.ts @@ -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); + }); + }); }); diff --git a/webapp/channels/src/utils/channel_utils.tsx b/webapp/channels/src/utils/channel_utils.tsx index 9d780f57796..9251c5f6a7c 100644 --- a/webapp/channels/src/utils/channel_utils.tsx +++ b/webapp/channels/src/utils/channel_utils.tsx @@ -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;