From d18f3dd278889d2644007f96fda3ccc7ebe0d4c6 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:39:53 +0200 Subject: [PATCH] [MM-69748] Add Hide Archived toggle to the Browse Channels modal (#37500) * [MM-69748] Add Hide Archived toggle to Browse Channels modal Archived channels previously appeared mixed in with active channels in the Browse Channels modal under the default "All channel types" filter (most visibly in search results), with no way to filter them out. Add a "Hide Archived" checkbox next to "Hide Joined" that hides archived channels by default. The toggle is backed by a persisted preference and is not shown when the explicit "Archived channels" filter is selected. Co-authored-by: mattermost-code * [MM-69748] Add tests for Hide Archived toggle in Browse Channels Cover the default-hidden behavior, toggling to show archived channels, the explicit Archived filter override, preference persistence, and the checkbox being hidden on the Archived filter. Co-authored-by: mattermost-code * [MM-69748] Strengthen Hide Archived tests per review Add non-search browse-list coverage for getActiveChannels in both directions, assert only archived rows are filtered from search, and query the checkbox by accessible label. Co-authored-by: mattermost-code * Fix archived channel leak/duplication in Browse Channels filters - Exclude archived private channels from the private channels selector so they no longer bypass the Hide Archived toggle or duplicate with the archived list. - Fold archived channels back into the Public/Private lists (by type) when the toggle is off, matching the All list behavior. - Keep the search Public filter governed by the shared hide-archived block instead of hard-excluding archived public channels. - Add regression tests for the mixed public/private archived scenario. * Satisfy eslint style rules in browse channels changes * Update Browse Channels E2E for Hide Archived default Align Cypress search expectations and Playwright tab/aria snapshots with the new Hide Archived checkbox (on by default). Co-authored-by: mattermost-code --------- Co-authored-by: Cursor Agent Co-authored-by: mattermost-code --- .../channels/channel/browse_channels_spec.ts | 12 +- .../channels/browse_channels_modal.ts | 2 + .../channels/browse_channels_dialog.spec.ts | 3 + .../searchable_channel_list.test.tsx.snap | 68 ++++++ .../browse_channels.test.tsx.snap | 68 ++++++ .../browse_channels/browse_channels.scss | 7 +- .../browse_channels/browse_channels.test.tsx | 225 ++++++++++++++++++ .../browse_channels/browse_channels.tsx | 45 +++- .../src/components/browse_channels/index.ts | 9 +- .../searchable_channel_list.test.tsx | 46 +++- .../components/searchable_channel_list.tsx | 34 +++ webapp/channels/src/i18n/en.json | 2 + webapp/channels/src/utils/constants.tsx | 1 + 13 files changed, 510 insertions(+), 12 deletions(-) diff --git a/e2e-tests/cypress/tests/integration/channels/channel/browse_channels_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel/browse_channels_spec.ts index 513d5c5f330..8c4c4575b95 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/browse_channels_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/channel/browse_channels_spec.ts @@ -245,11 +245,21 @@ describe('Channels', () => { cy.wrap(el).should('contain', channelType.all); }); - // * Users should be able to type and search + // * Hide Archived is on by default, so search only returns active matches + cy.get('#hideArchivedPreferenceCheckbox').should('be.visible').and('have.attr', 'aria-checked', 'true'); cy.get('#searchChannelsTextbox').should('be.visible').type('iv').wait(TIMEOUTS.HALF_SEC); + cy.get('#moreChannelsList').should('be.visible').children().should('have.length', 1); + cy.get('#moreChannelsList').should('be.visible').within(() => { + cy.findByText(newChannel.display_name).should('be.visible'); + cy.findByText(testArchivedChannel.display_name).should('not.exist'); + }); + + // # Uncheck Hide Archived so archived matches appear in All search results + cy.get('#hideArchivedPreferenceCheckbox').click(); cy.get('#moreChannelsList').should('be.visible').children().should('have.length', 2); cy.get('#moreChannelsList').should('be.visible').within(() => { cy.findByText(newChannel.display_name).should('be.visible'); + cy.findByText(testArchivedChannel.display_name).should('be.visible'); }); cy.get('#browseChannelsModal').should('be.visible'); diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/browse_channels_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/browse_channels_modal.ts index b539aff730b..f5968a6f293 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/browse_channels_modal.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/browse_channels_modal.ts @@ -8,6 +8,7 @@ export default class BrowseChannelsModal { readonly container: Locator; readonly createNewChannelButton: Locator; + readonly hideArchivedCheckbox: Locator; readonly hideJoinedCheckbox: Locator; readonly searchInput: Locator; @@ -17,6 +18,7 @@ export default class BrowseChannelsModal { this.container = container; this.createNewChannelButton = container.getByRole('button', {name: 'Create New Channel'}); + this.hideArchivedCheckbox = container.getByRole('checkbox', {name: 'Hide Archived'}); this.hideJoinedCheckbox = container.getByRole('checkbox', {name: 'Hide Joined'}); this.searchInput = container.getByRole('textbox', {name: 'Search channels'}); diff --git a/e2e-tests/playwright/specs/accessibility/channels/browse_channels_dialog.spec.ts b/e2e-tests/playwright/specs/accessibility/channels/browse_channels_dialog.spec.ts index 2583ecbab75..908e899617b 100644 --- a/e2e-tests/playwright/specs/accessibility/channels/browse_channels_dialog.spec.ts +++ b/e2e-tests/playwright/specs/accessibility/channels/browse_channels_dialog.spec.ts @@ -55,6 +55,7 @@ test( await hideJoinedCheckbox.click(); // # Focus on Create Channel button and tab through elements + // Tab order: Close → Search → Channel type filter → Hide Archived → Hide Joined → first channel const createChannelButton = dialog.createNewChannelButton; await createChannelButton.focus(); await page.keyboard.press('Tab'); @@ -62,6 +63,7 @@ test( await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); // * Verify channel name is highlighted and has proper aria-label await dialog.toHaveChannelAsNthResult(channel1.name, 0); @@ -116,6 +118,7 @@ test( - /status: \\d+ Results/ - status: Channel type filter set to All - button "Channel type filter" + - checkbox "Hide archived channels" [checked]: Hide Archived - checkbox "Hide joined channels": Hide Joined - search `); diff --git a/webapp/channels/src/components/__snapshots__/searchable_channel_list.test.tsx.snap b/webapp/channels/src/components/__snapshots__/searchable_channel_list.test.tsx.snap index aa2f6cca105..39deb4f4f83 100644 --- a/webapp/channels/src/components/__snapshots__/searchable_channel_list.test.tsx.snap +++ b/webapp/channels/src/components/__snapshots__/searchable_channel_list.test.tsx.snap @@ -75,6 +75,74 @@ exports[`components/SearchableChannelList should match init snapshot 1`] = ` /> +
+
{ teamName: 'team_name', channelsRequestStarted: false, shouldHideJoinedChannels: false, + shouldHideArchivedChannels: true, accessControlEnabled: false, myChannelMemberships: { 'channel-id-3': TestHelper.getChannelMembershipMock({ @@ -714,6 +715,230 @@ describe('components/BrowseChannels', () => { expect(baseProps.actions.getRecommendedChannelsForUser).not.toHaveBeenCalled(); }); + test('hides archived channels from All search results when shouldHideArchivedChannels is true', async () => { + const searchAllChannels = jest.fn(channelActions.searchAllChannels); + const props = {...baseProps, shouldHideArchivedChannels: true, actions: {...baseProps.actions, searchAllChannels}}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + const searchInput = screen.getByPlaceholderText('Search channels'); + await user.type(searchInput, 'channel'); + + await act(async () => { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + }); + + // The active public and private channels are shown, but the archived + // channel returned by the search is filtered out under the default + // "All" filter — proving only archived rows are removed. + await waitFor(() => { + expect(screen.getByText('Channel 1')).toBeInTheDocument(); + expect(screen.getByText('Private')).toBeInTheDocument(); + }); + expect(screen.queryByText('Archived')).not.toBeInTheDocument(); + }); + + test('hides archived channels from the browse list by default (no search)', async () => { + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + // baseProps.shouldHideArchivedChannels is true, so the archived channel + // ('channel-2') is absent from the default All browse list. + expect(screen.getByText('Default Channel')).toBeInTheDocument(); + expect(screen.queryByText('channel-2')).not.toBeInTheDocument(); + }); + + test('shows archived channels in the browse list when shouldHideArchivedChannels is false (no search)', async () => { + const props = {...baseProps, shouldHideArchivedChannels: false}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + // With the toggle off, the archived channel is mixed into the All list. + expect(screen.getByText('Default Channel')).toBeInTheDocument(); + expect(screen.getByText('channel-2')).toBeInTheDocument(); + }); + + test('shows archived channels in All search results when shouldHideArchivedChannels is false', async () => { + const searchAllChannels = jest.fn(channelActions.searchAllChannels); + const props = {...baseProps, shouldHideArchivedChannels: false, actions: {...baseProps.actions, searchAllChannels}}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + const searchInput = screen.getByPlaceholderText('Search channels'); + await user.type(searchInput, 'channel'); + + await act(async () => { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + }); + + // With the toggle off, archived channels are mixed back into the results. + await waitFor(() => { + expect(screen.getByText('Channel 1')).toBeInTheDocument(); + expect(screen.getByText('Archived')).toBeInTheDocument(); + }); + }); + + // Regression: archived private channels used to leak in via the + // privateChannels list (which is not filtered by the Hide Archived toggle) + // and, with the toggle off, appear twice — once from privateChannels and + // once from archivedChannels. The container selector now returns only + // active private channels, so archived channels of both types come solely + // from `archivedChannels`. + const archivedPublicChannel = TestHelper.getChannelMock({ + id: 'archived-public-id', + team_id: 'team_1', + display_name: 'Archived Public', + name: 'archived-public', + type: 'O', + delete_at: 123, + }); + + const archivedPrivateChannel = TestHelper.getChannelMock({ + id: 'archived-private-id', + team_id: 'team_1', + display_name: 'Archived Private', + name: 'archived-private', + type: 'P', + delete_at: 456, + }); + + const bothTypesArchivedProps: Props = { + ...baseProps, + channels: [defaultChannel], + privateChannels: [privateChannel], + archivedChannels: [archivedPublicChannel, archivedPrivateChannel], + }; + + test('hides both archived public and private channels from the All list when the toggle is on', async () => { + const props = {...bothTypesArchivedProps, shouldHideArchivedChannels: true}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByText('Default Channel')).toBeInTheDocument(); + expect(screen.queryByText('Archived Public')).not.toBeInTheDocument(); + expect(screen.queryByText('Archived Private')).not.toBeInTheDocument(); + }); + + test('shows each archived channel exactly once in the All list when the toggle is off', async () => { + const props = {...bothTypesArchivedProps, shouldHideArchivedChannels: false}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByText('Archived Public')).toBeInTheDocument(); + + // Exactly one row — previously the archived private channel rendered + // twice because it came from both privateChannels and archivedChannels. + expect(screen.getAllByText('Archived Private')).toHaveLength(1); + }); + + test('includes archived channels of the matching type under the Public and Private filters when the toggle is off', async () => { + const props = {...bothTypesArchivedProps, shouldHideArchivedChannels: false}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + await user.click(screen.getByLabelText('Channel type filter')); + await user.click(await screen.findByText('Public channels')); + + // Wait on the private row being pruned — "Archived Public" is present in + // both the All and Public views, so it isn't a reliable settle signal. + await waitFor(() => { + expect(screen.queryByText('Archived Private')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Archived Public')).toBeInTheDocument(); + + await user.click(screen.getByLabelText('Channel type filter')); + await user.click(await screen.findByText('Private channels')); + + await waitFor(() => { + expect(screen.queryByText('Archived Public')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Archived Private')).toBeInTheDocument(); + }); + + test('still shows archived channels when the Archived filter is selected even if shouldHideArchivedChannels is true', async () => { + const searchAllChannels = jest.fn(channelActions.searchAllChannels); + const props = {...baseProps, shouldHideArchivedChannels: true, actions: {...baseProps.actions, searchAllChannels}}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + await user.click(screen.getByLabelText('Channel type filter')); + await user.click(await screen.findByText('Archived channels')); + + const searchInput = screen.getByPlaceholderText('Search channels'); + await user.type(searchInput, 'channel'); + + await act(async () => { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByText('Archived')).toBeInTheDocument(); + }); + expect(screen.queryByText('Channel 1')).not.toBeInTheDocument(); + }); + + test('toggling Hide Archived persists the preference', async () => { + const setGlobalItem = jest.fn(); + const props = {...baseProps, shouldHideArchivedChannels: true, actions: {...baseProps.actions, setGlobalItem}}; + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + // Starts checked (hidden by default); unchecking persists 'false'. + const hideArchived = screen.getByLabelText('Hide archived channels'); + expect(hideArchived).toHaveAttribute('aria-checked', 'true'); + + await user.click(hideArchived); + + expect(setGlobalItem).toHaveBeenCalledWith('hideArchivedChannels', 'false'); + }); + + test('Hide Archived checkbox is not shown when the Archived filter is selected', async () => { + renderWithContext(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByLabelText('Hide archived channels')).toBeInTheDocument(); + + await user.click(screen.getByLabelText('Channel type filter')); + await user.click(await screen.findByText('Archived channels')); + + await waitFor(() => { + expect(screen.queryByLabelText('Hide archived channels')).not.toBeInTheDocument(); + }); + }); + // --------------------------------------------------------------- // Discoverable Private Channels — row state machine + filter chips // --------------------------------------------------------------- diff --git a/webapp/channels/src/components/browse_channels/browse_channels.tsx b/webapp/channels/src/components/browse_channels/browse_channels.tsx index 7a04086701d..bbb62d41419 100644 --- a/webapp/channels/src/components/browse_channels/browse_channels.tsx +++ b/webapp/channels/src/components/browse_channels/browse_channels.tsx @@ -87,6 +87,7 @@ export type Props = { channelsRequestStarted?: boolean; myChannelMemberships: RelationOneToOne; shouldHideJoinedChannels: boolean; + shouldHideArchivedChannels: boolean; rhsState?: RhsState; rhsOpen?: boolean; channelsMemberCount?: Record; @@ -363,7 +364,10 @@ export default class BrowseChannels extends React.PureComponent { searchedChannels = channels.filter((c) => c.type === Constants.PRIVATE_CHANNEL && this.canSeePrivateChannel(c)); } if (this.state.filter === Filter.Public) { - searchedChannels = channels.filter((c) => c.type === Constants.OPEN_CHANNEL && c.delete_at === 0); + // Archived public channels are pruned below when the Hide Archived + // toggle is on, so don't force delete_at === 0 here — that would + // hide them even when the toggle is off. + searchedChannels = channels.filter((c) => c.type === Constants.OPEN_CHANNEL); } if (this.state.filter === Filter.Archived) { searchedChannels = channels.filter((c) => c.delete_at !== 0); @@ -385,6 +389,9 @@ export default class BrowseChannels extends React.PureComponent { if (this.state.filter === Filter.MyPendingRequests) { searchedChannels = channels.filter((c) => this.props.myPendingJoinRequests[c.id]); } + if (this.state.filter !== Filter.Archived && this.props.shouldHideArchivedChannels) { + searchedChannels = this.getChannelsWithoutArchived(searchedChannels); + } if (this.props.shouldHideJoinedChannels) { searchedChannels = this.getChannelsWithoutJoined(searchedChannels); } @@ -428,10 +435,18 @@ export default class BrowseChannels extends React.PureComponent { this.props.actions.setGlobalItem(StoragePrefixes.HIDE_JOINED_CHANNELS, shouldHideJoinedChannels.toString()); }; + handleShowArchivedChannelsPreference = (shouldHideArchivedChannels: boolean) => { + // search again when toggling to update search results + this.search(this.state.searchTerm); + this.props.actions.setGlobalItem(StoragePrefixes.HIDE_ARCHIVED_CHANNELS, shouldHideArchivedChannels.toString()); + }; + getChannelsWithoutJoined = (channelList: Channel[]) => channelList.filter((channel) => !this.isMemberOfChannel(channel.id)); + getChannelsWithoutArchived = (channelList: Channel[]) => channelList.filter((channel) => channel.delete_at === 0); + getActiveChannels = () => { - const {channels, archivedChannels, shouldHideJoinedChannels, privateChannels, myPendingJoinRequests} = this.props; + const {channels, archivedChannels, shouldHideJoinedChannels, shouldHideArchivedChannels, privateChannels, myPendingJoinRequests} = this.props; const {search, searchedChannels, filter, recommendedChannels, discoverableChannels} = this.state; // Discoverable private channels the user is not yet a member of. These @@ -456,11 +471,23 @@ export default class BrowseChannels extends React.PureComponent { // appear in the default browse view, not only under the Discoverable // filter. privateChannels-sourced rows are already in allChannels. const extraDiscoverable = discoverableChannels.filter((c) => !privateChannels.some((p) => p.id === c.id)); - const allChannels = channels.concat(privateChannels, extraDiscoverable).sort((a, b) => a.display_name.localeCompare(b.display_name)); + + // `archivedChannels` holds both archived public and private channels. + // When the toggle is on they are dropped everywhere except the explicit + // Archived filter; when it is off they are folded back into the matching + // type-specific list so Public/Private show their archived rows too. + const visibleArchivedChannels = shouldHideArchivedChannels ? [] : archivedChannels; + const publicChannels = channels.concat(visibleArchivedChannels.filter((c) => c.type === Constants.OPEN_CHANNEL)); + const visiblePrivateChannels = privateChannels.concat(visibleArchivedChannels.filter((c) => c.type === Constants.PRIVATE_CHANNEL)); + + const allChannels = channels. + concat(privateChannels, extraDiscoverable). + concat(visibleArchivedChannels). + sort((a, b) => a.display_name.localeCompare(b.display_name)); const allChannelsWithoutJoined = this.getChannelsWithoutJoined(allChannels); - const publicChannelsWithoutJoined = this.getChannelsWithoutJoined(channels); + const publicChannelsWithoutJoined = this.getChannelsWithoutJoined(publicChannels); const archivedChannelsWithoutJoined = this.getChannelsWithoutJoined(archivedChannels); - const privateChannelsWithoutJoined = this.getChannelsWithoutJoined(privateChannels); + const privateChannelsWithoutJoined = this.getChannelsWithoutJoined(visiblePrivateChannels); const recommendedChannelsWithoutJoined = this.getChannelsWithoutJoined(recommendedChannels); // Channels the current user has pending requests against. The @@ -479,8 +506,8 @@ export default class BrowseChannels extends React.PureComponent { const filterOptions: Record = { [Filter.All]: shouldHideJoinedChannels ? allChannelsWithoutJoined : allChannels, [Filter.Archived]: shouldHideJoinedChannels ? archivedChannelsWithoutJoined : archivedChannels, - [Filter.Private]: shouldHideJoinedChannels ? privateChannelsWithoutJoined : privateChannels, - [Filter.Public]: shouldHideJoinedChannels ? publicChannelsWithoutJoined : channels, + [Filter.Private]: shouldHideJoinedChannels ? privateChannelsWithoutJoined : visiblePrivateChannels, + [Filter.Public]: shouldHideJoinedChannels ? publicChannelsWithoutJoined : publicChannels, [Filter.Recommended]: shouldHideJoinedChannels ? recommendedChannelsWithoutJoined : recommendedChannels, [Filter.Discoverable]: discoverableNonMember, [Filter.MyPendingRequests]: myPending, @@ -498,7 +525,7 @@ export default class BrowseChannels extends React.PureComponent { }; render() { - const {teamId, channelsRequestStarted, shouldHideJoinedChannels} = this.props; + const {teamId, channelsRequestStarted, shouldHideJoinedChannels, shouldHideArchivedChannels} = this.props; const {search, serverError: serverErrorState, searching} = this.state; this.activeChannels = this.getActiveChannels(); @@ -566,6 +593,8 @@ export default class BrowseChannels extends React.PureComponent { closeModal={this.props.actions.closeModal} hideJoinedChannelsPreference={this.handleShowJoinedChannelsPreference} rememberHideJoinedChannelsChecked={shouldHideJoinedChannels} + hideArchivedChannelsPreference={this.handleShowArchivedChannelsPreference} + rememberHideArchivedChannelsChecked={shouldHideArchivedChannels} channelsMemberCount={this.props.channelsMemberCount} /> {serverError} diff --git a/webapp/channels/src/components/browse_channels/index.ts b/webapp/channels/src/components/browse_channels/index.ts index 03cdbc62fe7..6492617f345 100644 --- a/webapp/channels/src/components/browse_channels/index.ts +++ b/webapp/channels/src/components/browse_channels/index.ts @@ -52,12 +52,18 @@ const getArchivedOtherChannels = createSelector( const getPrivateChannelsSelector = createSelector( 'getPrivateChannelsSelector', getChannelsInCurrentTeam, - (channels: Channel[]) => channels && channels.filter((c) => c.type === Constants.PRIVATE_CHANNEL), + + // Active private channels only. Archived private channels are surfaced + // exclusively through `archivedChannels`; including them here would both + // leak them past the Hide Archived toggle and double them up alongside the + // archived list. + (channels: Channel[]) => channels && channels.filter((c) => c.delete_at === 0 && c.type === Constants.PRIVATE_CHANNEL), ); function mapStateToProps(state: GlobalState) { const team = getCurrentTeam(state); const getGlobalItem = makeGetGlobalItem(StoragePrefixes.HIDE_JOINED_CHANNELS, 'false'); + const getHideArchivedItem = makeGetGlobalItem(StoragePrefixes.HIDE_ARCHIVED_CHANNELS, 'true'); return { channels: getChannelsWithoutArchived(state) || [], @@ -69,6 +75,7 @@ function mapStateToProps(state: GlobalState) { channelsRequestStarted: state.requests.channels.getChannels.status === RequestStatus.STARTED, myChannelMemberships: getMyChannelMemberships(state) || {}, shouldHideJoinedChannels: getGlobalItem(state) === 'true', + shouldHideArchivedChannels: getHideArchivedItem(state) === 'true', rhsState: getRhsState(state), rhsOpen: getIsRhsOpen(state), channelsMemberCount: getChannelsMemberCountSelector(state), diff --git a/webapp/channels/src/components/searchable_channel_list.test.tsx b/webapp/channels/src/components/searchable_channel_list.test.tsx index 4e300c92bae..a9604d97df4 100644 --- a/webapp/channels/src/components/searchable_channel_list.test.tsx +++ b/webapp/channels/src/components/searchable_channel_list.test.tsx @@ -13,7 +13,7 @@ import {compassIconForName} from 'components/channel_type_icon'; import {SearchableChannelList} from 'components/searchable_channel_list'; import {type MockIntl} from 'tests/helpers/intl-test-helper'; -import {renderWithContext, screen} from 'tests/react_testing_utils'; +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; import {Filter} from './browse_channels/browse_channels'; @@ -48,10 +48,12 @@ describe('components/SearchableChannelList', () => { toggleArchivedChannels: jest.fn(), closeModal: jest.fn(), hideJoinedChannelsPreference: jest.fn(), + hideArchivedChannelsPreference: jest.fn(), changeFilter: jest.fn(), myChannelMemberships: {}, canShowArchivedChannels: false, rememberHideJoinedChannelsChecked: false, + rememberHideArchivedChannelsChecked: true, noResultsText: <>{'no channel found'}, filter: Filter.All, intl: { @@ -98,6 +100,48 @@ describe('components/SearchableChannelList', () => { expect(baseProps.search).toBeDefined(); }); + test('renders the Hide Archived checkbox reflecting the persisted preference', () => { + renderWithContext( + , + initialState, + ); + + const hideArchived = screen.getByLabelText('Hide archived channels'); + expect(hideArchived).toBeInTheDocument(); + expect(hideArchived).toHaveAttribute('aria-checked', 'true'); + }); + + test('does not render the Hide Archived checkbox when the Archived filter is active', () => { + renderWithContext( + , + initialState, + ); + + expect(screen.queryByLabelText('Hide archived channels')).not.toBeInTheDocument(); + }); + + test('clicking the Hide Archived checkbox toggles the preference', async () => { + const hideArchivedChannelsPreference = jest.fn(); + renderWithContext( + , + initialState, + ); + + const hideArchived = screen.getByLabelText('Hide archived channels'); + expect(hideArchived).toHaveAttribute('aria-checked', 'false'); + + await userEvent.click(hideArchived); + + expect(hideArchivedChannelsPreference).toHaveBeenCalledWith(true); + }); + test('should render ArchiveOutlineIcon for archived public channels', () => { const channels = [ { diff --git a/webapp/channels/src/components/searchable_channel_list.tsx b/webapp/channels/src/components/searchable_channel_list.tsx index b6c154e5d00..fd2b42fe45a 100644 --- a/webapp/channels/src/components/searchable_channel_list.tsx +++ b/webapp/channels/src/components/searchable_channel_list.tsx @@ -42,6 +42,8 @@ interface Props extends WrappedComponentProps { closeModal: (modalId: string) => void; hideJoinedChannelsPreference: (shouldHideJoinedChannels: boolean) => void; rememberHideJoinedChannelsChecked: boolean; + hideArchivedChannelsPreference: (shouldHideArchivedChannels: boolean) => void; + rememberHideArchivedChannelsChecked: boolean; loading?: boolean; channelsMemberCount?: Record; showRecommendedFilter?: boolean; @@ -451,6 +453,9 @@ export class SearchableChannelList extends React.PureComponent { this.props.hideJoinedChannelsPreference(true); } }; + handleArchivedChecked = () => { + this.props.hideArchivedChannelsPreference(!this.props.rememberHideArchivedChannelsChecked); + }; getEmptyStateMessage = () => { if (this.state.channelSearchValue.length > 0) { return ( @@ -851,6 +856,34 @@ export class SearchableChannelList extends React.PureComponent {
); + // The archived filter explicitly asks for archived channels, so hiding + // them there would leave an empty list — only offer the toggle elsewhere. + const hideArchivedButtonClass = classNames('get-app__checkbox', {checked: this.props.rememberHideArchivedChannelsChecked}); + const hideArchivedPreferenceCheckbox = this.props.filter === Filter.Archived ? null : ( +
{ + e.stopPropagation(); + if (e.key === 'Enter' || e.key === ' ') { + this.handleArchivedChecked(); + } + }} + role='checkbox' + aria-checked={this.props.rememberHideArchivedChannelsChecked} + aria-label={this.props.intl.formatMessage({id: 'more_channels.hide_archived_channels', defaultMessage: 'Hide archived channels'})} + tabIndex={0} + > +
+ {this.props.rememberHideArchivedChannelsChecked ? : null} +
+ +
+ ); + let channelCountLabel; if (channels.length === 0) { channelCountLabel = this.props.intl.formatMessage({id: 'more_channels.count_zero', defaultMessage: '0 Results'}); @@ -874,6 +907,7 @@ export class SearchableChannelList extends React.PureComponent {
{channelDropdown} + {hideArchivedPreferenceCheckbox} {hideJoinedPreferenceCheckbox}
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 3d6760a9ede..1ccdaa04cf3 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -6086,6 +6086,8 @@ "more_channels.create": "Create New Channel", "more_channels.discoverable": "Discoverable", "more_channels.discoverable.aria": "Discoverable private channel", + "more_channels.hide_archived": "Hide Archived", + "more_channels.hide_archived_channels": "Hide archived channels", "more_channels.hide_joined": "Hide Joined", "more_channels.hide_joined_channels": "Hide joined channels", "more_channels.joined": "Joined", diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index b4be69e3fe6..d47bbbeed15 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -834,6 +834,7 @@ export const StoragePrefixes = { INLINE_IMAGE_VISIBLE: 'isInlineImageVisible_', DELINQUENCY: 'delinquency_', HIDE_JOINED_CHANNELS: 'hideJoinedChannels', + HIDE_ARCHIVED_CHANNELS: 'hideArchivedChannels', HIDE_NOTIFICATION_PERMISSION_REQUEST_BANNER: 'hideNotificationPermissionRequestBanner', MARK_ALL_READ_WITHOUT_CONFIRM: 'mark_all_as_read_without_confirm', HAS_SEEN_FEATURE_TOAST: 'has_seen_feature_toast',