From 10cf522a5f3582966d8383ccf0d72bbe496d8de7 Mon Sep 17 00:00:00 2001 From: "Sinan Sonmez (Chaush)" <37421564+sinansonmez@users.noreply.github.com> Date: Thu, 15 Jun 2023 21:10:13 +0200 Subject: [PATCH] MM-42267: Update browse channel UI (#23532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update more_channels for ui and show all channels * update searchable channel list * fix style * fix tests * fix style for delete icon * fix failing tests * fix style * remove dot * remove header button * put back header button * Fix duplicate keys in CombinedSystemMessage component (#23507) * MM-52873 : Switch to npm's 'reselect' for Playbooks (#23396) * Enable golangci-lint (attempt 2) (#23517) This time we are just using the Makefile command to see if that makes a difference ```release-note NONE ``` * MM-52888 Always load users mentioned in message attachments (#23460) Co-authored-by: Mattermost Build * Prepare: run E2E smoketests with GitHub actions (#23301) - Port E2E testing scripts from cypress-ui-automation - Move server to docker-compose, move E2E images to ecrpublic * Integrate General channel renaming, fixes - Add local automation-dashboard - Add readme - Add E2E smoketests - Bump postgres to 12 --------- Co-authored-by: Mattermost Build Co-authored-by: Saturnino Abril Co-authored-by: Antonis Stamatiou * MM-47064/MM-34345/MM-47072 Remove inheritance from Suggestion components and migrate to TS (#23455) * MM-47064 Remove inheritance from Suggestion components * Address feedback * Fix users without DM channels not appearing in the channel switcher --------- Co-authored-by: Mattermost Build * MM-52544 Fix missing reply bar mention highlight (#23534) * MM-52513: fixes deleting a reply (#23177) * MM-52513: fixes deleting a reply Currently when we receive a WS event for a reply being deleted we might accidentally push it to a wrong team's store. This might happen if the thread is already loaded in store and we are viewing another team. In that case we were fetching the thread from the API using the team id of the current team. The API returns the thread, even though the team id is not the one which the thread belongs to. This commit is fixing the above issue by getting the team id in which the thread belongs to, or current team id in the case of DM/GM messages, and using that to fetch the thread from the API. PS: the fetching is needed since we don't send a thread_update WS event upon deleting a reply, and we need to get the new participants list. * Fixes team id on another occasion * Refactors a bit * Reverts returning empty string as team id * Refactor a bit to pass the post as argument --------- Co-authored-by: Kyriakos Ziakoulis Co-authored-by: Kyriakos Ziakoulis Co-authored-by: Harrison Healey * Avoid calling the user count query in future if we get a count > 0 (#23545) * Avoid calling the user count query in future if we get a count > 0 * re-adding mock session to avoid adding the old mitigation in future * adjustments based on feedback * MM-52365 - fix JS error banner (#23501) * MM-52365 - fix js error banner * add null type to bindings as an optional type * Make used of typed atomic.Pointer (#23550) * Make save_post_spec less affected by other E2E tests (#23541) * Revert "Prepare: run E2E smoketests with GitHub actions (#23301)" (#23553) This reverts commit 68be3a6bcd7458193d0fde04d82f796d0730450f. * adding debug log when executing query (#23559) * server/docker-compose.yml: updates to run HA in local after monorepo (#23552) * [MM-52919] Remove all uses of window.desktop.version from webapp (#23558) Co-authored-by: Mattermost Build * [MM-21096] webapp: Migrate "components/suggestion/search_channel_with_permissions_provider.jsx" to Typescript (#23323) * migrate to ts * linting * fix test * refactor * Remove accidental comments from merge --------- Co-authored-by: Mattermost Build Co-authored-by: Harrison Healey * fix server ci after renaming the project (#23576) * Temporarily let AdvancedLoggingConfig take precedence over AdvancedLoggingJSON (#23578) * Temporarily let AdvancedLoggingConfig take precedence over AdvancedLoggingJSON * Repo name ci fixes (#23569) * mattermost-server -> mattermost * mattermost-server -> mattermost * Empty-Commit * Empty-Commit * Empty-Commit --------- Co-authored-by: Antonis Stamatiou Co-authored-by: Mattermost Build Co-authored-by: Akis Maziotis * MM-51585 : Fix duplicated date separator in center channel when Pinned posts RHS is open (#23068) * Fix references from 'packages/*' to 'platform/*' in READMEs (#23498) * [MM-52541] Mark files as deleted along with thread (#23226) * mark thread files as deleted * add missing check * improve query * Stopped rendering post preview if post is deleted * Fixed lint error * Fixed test * updated types * Removed deleted post from other post's embed data * Added tests * Apply suggestions from code review Co-authored-by: Daniel Espino García * lint fix --------- Co-authored-by: Konstantinos Pittas Co-authored-by: Harshil Sharma Co-authored-by: Mattermost Build Co-authored-by: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Co-authored-by: Daniel Espino García * MM-52476 Fix guest users access to playbooks. (#23279) * Fix guest users access to playbooks. * Fix guest access. * Add guests list restriction. * Update PULL_REQUEST_TEMPLATE.md to include Jira ticket for contributors (#23589) * [MM-52836] : Migrate "components/admin_console/admin_definition_constants.jsx" to TypeScript (#23566) * MM-52297 Fix reactions disappearing with search open and add testing utilities (#23510) * MM-52297 Fix reactions disappearing when search is open * Add unit tests and extra utilities * Fix typing issue * MM-53002: Fix ESR CI (#23599) * Do not use the mattermost path The /mattermost path is used by the image and it seems to conflict with the container running the binary * Use a regular machine in the esr-upgrade-diff job * Add missing space * [MM-52955] Fix panic for not found posts (#23561) * [MM-52973] Avoid thundering herd problem in IsFirstUserAccount (#23549) * [MM-45802] Clear CRT notification on deleted reply (#23568) * reduce the counter on post deletion * add test * change translations * fix collecting mentions for DMs * add translation texts * extract logic for getting mentions * send WS event * add e2e tests * tidy mod * WIP * Deleting notification async * Fixed a unit test * Added more tests * Updated i18n * CI * mattermost-server -> mattermost * mattermost-server -> mattermost --------- Co-authored-by: Konstantinos Pittas Co-authored-by: Mattermost Build * Includes mmctl into the mono-repo (#23091) * Includes mmctl into the mono-repo * Update to use the new public module paths * Adds docs check to the mmctl CI * Fix public utils import path * Tidy up modules * Fix linter * Update CI tasks to use the new file structure * Update CI references * [MM-30432]: Allow users to specify different desktop notification sounds per channel (#21671) * [MM-44165]: When Help link is left blank Help Resources option should not be visible in the help menu (#23609) * Clean up at .github due to repo rename (#23580) * update .github after repo rename * update * Update PULL_REQUEST_TEMPLATE.md --------- Co-authored-by: M-ZubairAhmed Co-authored-by: Mattermost Build * MM-52819 : "medical_symbol", "male_sign" and "female_sign" emojis are broken (#23538) * [MM-52979]: Remove code around abandoned MUI modal migration (#23556) * Re-export all React Testing Library functions (#23522) * Fix panic if JSON null value is passed as channel update (#23629) * MM-52818 - create config setting to enable/disable playbooks product (#23508) * create config setting to enable/disable playbooks product * fix to config name * fix typo * revert changes to package-lock.json * update name of test --------- Co-authored-by: Mattermost Build * [MM-52926] Deprecating work templates (#23466) Co-authored-by: Mattermost Build * [MM-52839]: Migrate "components/admin_console/remove_file_setting.jsx" to Typescript (#23565) * [MM-52833]: Migrate "components/admin_console/multiselect_settings.jsx" to Typescript (#23542) * [MM-52835]: Migrate "components/admin_console/settings_group.jsx" and tests to Typescript (#23563) * fix typo in index name for idx_teammembers_create_at (#23632) * [MM-49989] Pass a context.Context to Client4 methods (#22922) * Migrate all method in model/client4.go to accept a context.Context * Fix th.*Client * Fix remaining issues * Empty commit to triger CI * Fix test * Add cancellation test * Test that returned error is context.Canceled * Fix bad merge * Update mmctl code --------- Co-authored-by: Mattermost Build * Service environment (#23443) * fix fileutils.TestFindFile on MacOS * introduce model.ExternalServiceEnvironment * pick license public key from external service env * pick Stripe public key from external service env * pick Rudder key from external service env * configure Sentry DSN from external service env * always log external_service_environment, Unsetenv * clear faked BuildEnv, improve logging * strip out unset GOTAGS * fix Sentry tests * simplify to just ServiceEnvironment * relocate ServiceEnvironment in client config * initialize CWS URLs based on service environment * unset rudder key for boards dev * harden service environment to avoid accidental production * fix TestSentry again * fix DEFAULT -> ENTERPRISE * s/dev/test when naming playbooks rudder key * simplify boards rudder key switch * use uniform rudderKey variable names * retain compatibility with existing pipeline * reduce to just production/test * unit test with valid test license * simplify Playbooks telemetry initialization * restore dev service environment * emit ServiceEnvironment when running e2e tests * [MM 22957] webapp a11y: fix sso btns focus issue (#23326) * make suggested changes * added form tag and removed event handler * fix snapshot --------- Co-authored-by: Mattermost Build * Upgrade docker CI image to 23.0.1 (#23664) Automatic Merge * MM-52995: Fix opening DM/GM thread from thread footer (#23579) * MM-52487: fix more playbooks tests (#23475) * fix playbooks/channels/rhs/template_spec.js * fix playbooks/channels/update_request_post_spec.js * fix playbooks/runs/rdp_rhs_runinfo_spec.js * fix playbooks/runs/rdp_rhs_statusupdates_spec.js * remove enableexperimentalfeatures flag in e2e tests * rdp_main_header_spec: simplify channel loaded assertion * playbooks rhs participants: fix infinite fetch loop * improved onboarding skipping * simplify participants fetching * Support json.RawMessage in configuration env overrides (#23610) * support json.RawMessage in env overrides * update more_channels for ui and show all channels * remove header button * put back header button * [MM-52979]: Remove code around abandoned MUI modal migration (#23556) * fix import * update snapshot * Update e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_modals_dialogs_spec.js * fix e2e tests --------- Co-authored-by: Mattermost Build Co-authored-by: Harrison Healey Co-authored-by: M-ZubairAhmed Co-authored-by: Agniva De Sarker Co-authored-by: mvitale1989 Co-authored-by: Saturnino Abril Co-authored-by: Antonis Stamatiou Co-authored-by: Kyriakos Z <3829551+koox00@users.noreply.github.com> Co-authored-by: Kyriakos Ziakoulis Co-authored-by: Kyriakos Ziakoulis Co-authored-by: Ben Cooke Co-authored-by: Pablo Andrés Vélez Vidal Co-authored-by: Ben Schumacher Co-authored-by: Ibrahim Serdar Acikgoz Co-authored-by: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Co-authored-by: Sai Deepesh Co-authored-by: Doug Lauder Co-authored-by: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Co-authored-by: Akis Maziotis Co-authored-by: Hideaki Matsunami Co-authored-by: Konstantinos Pittas Co-authored-by: Konstantinos Pittas Co-authored-by: Harshil Sharma Co-authored-by: Daniel Espino García Co-authored-by: Christopher Speller Co-authored-by: Tejas Karelia Co-authored-by: Alejandro García Montoro Co-authored-by: Miguel de la Cruz Co-authored-by: KyeongSoo Kim Co-authored-by: Matheus <20505926+MattSilvaa@users.noreply.github.com> Co-authored-by: Scott Bishel Co-authored-by: Julien Tant <785518+JulienTant@users.noreply.github.com> Co-authored-by: Karan Mishra Co-authored-by: Judy Hanson <106325339+Esterjudith@users.noreply.github.com> Co-authored-by: Jesse Hallam Co-authored-by: Caleb Roseland --- .../channel/archived_channels_1_spec.js | 6 +- .../channels/channel/more_channels_spec.js | 4 +- .../channel/more_public_channels_spec.js | 13 +- .../new_channel_dropdown_spec.ts | 6 +- .../accessibility_modals_dialogs_spec.js | 26 +- .../enterprise/ldap/ldap_group_sync_spec.js | 4 +- .../src/actions/channel_actions.test.ts | 2 +- .../channels/src/actions/channel_actions.ts | 6 +- .../searchable_channel_list.test.jsx.snap | 72 ++- .../magnifying_glass_svg.tsx | 39 ++ .../__snapshots__/more_channels.test.tsx.snap | 188 ++++--- .../src/components/more_channels/index.ts | 20 +- .../more_channels/more_channels.scss | 297 +++++++++++ .../more_channels/more_channels.test.tsx | 23 +- .../more_channels/more_channels.tsx | 176 ++++--- .../components/searchable_channel_list.jsx | 319 ------------ .../searchable_channel_list.test.jsx | 14 +- .../components/searchable_channel_list.tsx | 481 ++++++++++++++++++ webapp/channels/src/i18n/en.json | 19 +- .../components/_channel-invite-modal.scss | 2 + webapp/channels/src/utils/constants.tsx | 1 + .../src/generic_modal/generic_modal.tsx | 2 + 22 files changed, 1145 insertions(+), 575 deletions(-) create mode 100644 webapp/channels/src/components/common/svg_images_components/magnifying_glass_svg.tsx create mode 100644 webapp/channels/src/components/more_channels/more_channels.scss delete mode 100644 webapp/channels/src/components/searchable_channel_list.jsx create mode 100644 webapp/channels/src/components/searchable_channel_list.tsx diff --git a/e2e-tests/cypress/tests/integration/channels/channel/archived_channels_1_spec.js b/e2e-tests/cypress/tests/integration/channels/channel/archived_channels_1_spec.js index d62984cca0..e2126cd3b8 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/archived_channels_1_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/channel/archived_channels_1_spec.js @@ -146,7 +146,7 @@ describe('Leave an archived channel', () => { cy.get('#showMoreChannels').click(); // # More channels modal opens - cy.get('.more-modal').should('be.visible').within(() => { + cy.get('#moreChannelsModal').should('be.visible').within(() => { // # Public channel list opens by default cy.findByText(channelType.public).should('be.visible').click(); @@ -199,7 +199,7 @@ describe('Leave an archived channel', () => { cy.get('#showMoreChannels').click(); // # More channels modal opens - cy.get('.more-modal').should('be.visible').within(() => { + cy.get('#moreChannelsModal').should('be.visible').within(() => { // # Public channels are shown by default cy.findByText(channelType.public).should('be.visible').click(); @@ -253,7 +253,7 @@ describe('Leave an archived channel', () => { cy.get('#showMoreChannels').click(); // # More channels modal opens - cy.get('.more-modal').should('be.visible').within(() => { + cy.get('#moreChannelsModal').should('be.visible').within(() => { // # Show public channels is visible by default cy.findByText(channelType.public).should('be.visible').click(); diff --git a/e2e-tests/cypress/tests/integration/channels/channel/more_channels_spec.js b/e2e-tests/cypress/tests/integration/channels/channel/more_channels_spec.js index d7fd550e37..768508b27e 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/more_channels_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/channel/more_channels_spec.js @@ -85,8 +85,8 @@ describe('Channels', () => { }); }); - // # Verify that the modal is closed and it's redirected to the selected channel - cy.get('#moreChannelsModal').should('not.exist'); + // # Verify that the modal is not closed + cy.get('#moreChannelsModal').should('exist'); cy.url().should('include', `/${testTeam.name}/channels/${testChannel.name}`); // # Login as channel admin and go directly to the channel diff --git a/e2e-tests/cypress/tests/integration/channels/channel/more_public_channels_spec.js b/e2e-tests/cypress/tests/integration/channels/channel/more_public_channels_spec.js index 8624b60edf..381d621e6a 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/more_public_channels_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/channel/more_public_channels_spec.js @@ -11,8 +11,7 @@ // Group: @channels @channel function verifyNoChannelToJoinMessage(isVisible) { - cy.findByText('No more channels to join').should(isVisible ? 'be.visible' : 'not.exist'); - cy.findByText('Click \'Create New Channel\' to make a new one').should(isVisible ? 'be.visible' : 'not.exist'); + cy.findByText('No public channels').should(isVisible ? 'be.visible' : 'not.exist'); } describe('more public channels', () => { @@ -53,7 +52,10 @@ describe('more public channels', () => { cy.uiBrowseOrCreateChannel('Browse channels').click(); // * Assert that the moreChannelsModel is visible - cy.findByRole('dialog', {name: 'More Channels'}).should('be.visible').within(() => { + cy.findByRole('dialog', {name: 'Browse Channels'}).should('be.visible').within(() => { + // # Click hide joined checkbox + cy.findByText('Hide Joined').should('be.visible').click(); + // * Assert that the moreChannelsList is visible and the number of channels is 31 cy.get('#moreChannelsList').should('be.visible').children().should('have.length', 31); @@ -86,10 +88,7 @@ describe('more public channels', () => { cy.uiBrowseOrCreateChannel('Browse channels').click(); // * Assert the moreChannelsModel is visible - cy.findByRole('dialog', {name: 'More Channels'}).should('be.visible').within(() => { - // * Assert the moreChannelsList does have one child - cy.get('#moreChannelsList').should('be.visible').children().should('have.length', 1); - + cy.findByRole('dialog', {name: 'Browse Channels'}).should('be.visible').within(() => { // * Assert that the "No more channels to join" message is visible verifyNoChannelToJoinMessage(true); }); diff --git a/e2e-tests/cypress/tests/integration/channels/channel_sidebar/new_channel_dropdown_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel_sidebar/new_channel_dropdown_spec.ts index c1eb246381..777966ed97 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel_sidebar/new_channel_dropdown_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/channel_sidebar/new_channel_dropdown_spec.ts @@ -68,13 +68,13 @@ describe('Channel sidebar', () => { cy.get('.AddChannelDropdown .MenuItem:contains(Browse channels) button').should('be.visible').click(); // * Verify that the more channels modal is visible - cy.get('.more-modal').should('be.visible'); + cy.get('#moreChannelsModal').should('be.visible'); // Click the Off-Topic channel - cy.get('.more-modal button:contains(Off-Topic)').should('be.visible').click(); + cy.findByText('Off-Topic').should('be.visible').click(); // Verify that new channel is in the sidebar and is active - cy.get('.more-modal').should('not.exist'); + cy.get('#moreChannelsModal').should('exist'); cy.url().should('include', `/${teamName}/channels/off-topic`); cy.get('#channelHeaderTitle').should('contain', 'Off-Topic'); cy.get('.SidebarChannel.active:contains(Off-Topic)').should('be.visible'); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_modals_dialogs_spec.js b/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_modals_dialogs_spec.js index ae6df74721..8483be2867 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_modals_dialogs_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_modals_dialogs_spec.js @@ -104,30 +104,26 @@ describe('Verify Accessibility Support in Modals & Dialogs', () => { cy.uiBrowseOrCreateChannel('Browse channels').click(); // * Verify the accessibility support in More Channels Dialog - cy.findByRole('dialog', {name: 'More Channels'}).within(() => { - cy.findByRole('heading', {name: 'More Channels'}); + cy.findByRole('dialog', {name: 'Browse Channels'}).within(() => { + cy.findByRole('heading', {name: 'Browse Channels'}); // * Verify the accessibility support in search input cy.findByPlaceholderText('Search channels'); - cy.waitUntil(() => cy.get('#moreChannelsList').then((el) => { + cy.get('#moreChannelsList').should('be.visible').then((el) => { return el[0].children.length === 2; - })); + }); - // # Focus on the Create Channel button and TAB twice - cy.get('#createNewChannel').focus().tab().tab(); + // # Hide already joined channels + cy.findByText('Hide Joined').click(); + + // # Focus on the Create Channel button and TAB three time + cy.get('#createNewChannelButton').focus().tab().tab().tab(); // * Verify channel name is highlighted and reader reads the channel name and channel description - cy.get('#moreChannelsList').children().eq(0).within(() => { + cy.get('#moreChannelsList').within(() => { const selectedChannel = getChannelAriaLabel(channel); - cy.findByLabelText(selectedChannel).should('be.focused'); - - // * Press Tab and verify if focus changes to Join button - cy.focused().tab(); - cy.findByText('Join').parent().should('be.focused'); - - // * Verify previous button should no longer be focused - cy.findByLabelText(selectedChannel).should('not.be.focused'); + cy.findByLabelText(selectedChannel).should('be.visible').should('be.focused'); }); // * Press Tab again and verify if focus changes to next row diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/ldap/ldap_group_sync_spec.js b/e2e-tests/cypress/tests/integration/channels/enterprise/ldap/ldap_group_sync_spec.js index a509642532..ed73e0f277 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/ldap/ldap_group_sync_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/ldap/ldap_group_sync_spec.js @@ -234,7 +234,7 @@ context('ldap', () => { // * Search private channel name and make sure it isn't there in public channel directory cy.get('#searchChannelsTextbox').type(testChannel.display_name); - cy.get('#moreChannelsList').should('include.text', 'No more channels to join'); + cy.get('#moreChannelsList').should('include.text', 'No results for'); }); it('MM-T2629 - Private to public - More....', () => { @@ -473,7 +473,7 @@ context('ldap', () => { // * Search private channel name and make sure it isn't there in public channel directory cy.get('#searchChannelsTextbox').type(publicChannel.display_name); - cy.get('#moreChannelsList').should('include.text', 'No more channels to join'); + cy.get('#moreChannelsList').should('include.text', 'No results for'); }); }); diff --git a/webapp/channels/src/actions/channel_actions.test.ts b/webapp/channels/src/actions/channel_actions.test.ts index 6b247396b2..aac40512ec 100644 --- a/webapp/channels/src/actions/channel_actions.test.ts +++ b/webapp/channels/src/actions/channel_actions.test.ts @@ -167,7 +167,7 @@ describe('Actions.Channel', () => { }], }]; - await testStore.dispatch(searchMoreChannels('', false)); + await testStore.dispatch(searchMoreChannels('', false, true)); expect(testStore.getActions()).toEqual(expectedActions); }); diff --git a/webapp/channels/src/actions/channel_actions.ts b/webapp/channels/src/actions/channel_actions.ts index b3c9ffb156..0add017514 100644 --- a/webapp/channels/src/actions/channel_actions.ts +++ b/webapp/channels/src/actions/channel_actions.ts @@ -109,7 +109,7 @@ export function loadChannelsForCurrentUser(): ActionFunc { }; } -export function searchMoreChannels(term: string, showArchivedChannels: boolean): ActionFunc { +export function searchMoreChannels(term: string, showArchivedChannels: boolean, hideJoinedChannels: boolean): ActionFunc { return async (dispatch, getState) => { const state = getState(); const teamId = getCurrentTeamId(state); @@ -121,9 +121,7 @@ export function searchMoreChannels(term: string, showArchivedChannels: boolean): const {data, error} = await dispatch(ChannelActions.searchChannels(teamId, term, showArchivedChannels)); if (data) { const myMembers = getMyChannelMemberships(state); - - // When searching public channels, only get channels user is not a member of - const channels = showArchivedChannels ? data : (data as Channel[]).filter((c) => !myMembers[c.id]); + const channels = hideJoinedChannels ? (data as Channel[]).filter((channel) => !myMembers[channel.id]) : data; return {data: channels}; } diff --git a/webapp/channels/src/components/__snapshots__/searchable_channel_list.test.jsx.snap b/webapp/channels/src/components/__snapshots__/searchable_channel_list.test.jsx.snap index eff77678a4..1118d16bf2 100644 --- a/webapp/channels/src/components/__snapshots__/searchable_channel_list.test.jsx.snap +++ b/webapp/channels/src/components/__snapshots__/searchable_channel_list.test.jsx.snap @@ -7,34 +7,70 @@ exports[`components/SearchableChannelList should match init snapshot 1`] = `
- +
+ + 0 Results + +
+
+
diff --git a/webapp/channels/src/components/common/svg_images_components/magnifying_glass_svg.tsx b/webapp/channels/src/components/common/svg_images_components/magnifying_glass_svg.tsx new file mode 100644 index 0000000000..75b2ec8d3d --- /dev/null +++ b/webapp/channels/src/components/common/svg_images_components/magnifying_glass_svg.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {SVGProps} from 'react'; + +const SvgComponent = (props: SVGProps) => ( + + + + + + + +); + +export default SvgComponent; diff --git a/webapp/channels/src/components/more_channels/__snapshots__/more_channels.test.tsx.snap b/webapp/channels/src/components/more_channels/__snapshots__/more_channels.test.tsx.snap index fe1acbf101..eb0a190f2c 100644 --- a/webapp/channels/src/components/more_channels/__snapshots__/more_channels.test.tsx.snap +++ b/webapp/channels/src/components/more_channels/__snapshots__/more_channels.test.tsx.snap @@ -1,53 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`components/MoreChannels should match snapshot and state 1`] = ` - - - - - - - - - - + } + id="moreChannelsModal" + keyboardEscape={true} + modalHeaderText={ + + } + onExited={[Function]} + show={true} +> + +

+ +

-

- -

+ +
- } - search={[Function]} - shouldShowArchivedChannels={false} - toggleArchivedChannels={[Function]} - /> -
-
+ + } + rememberHideJoinedChannelsChecked={false} + search={[Function]} + shouldShowArchivedChannels={false} + toggleArchivedChannels={[Function]} + /> + `; diff --git a/webapp/channels/src/components/more_channels/index.ts b/webapp/channels/src/components/more_channels/index.ts index 80bfd4a8c1..ff084cb172 100644 --- a/webapp/channels/src/components/more_channels/index.ts +++ b/webapp/channels/src/components/more_channels/index.ts @@ -12,7 +12,7 @@ import {Action, ActionResult} from 'mattermost-redux/types/actions'; import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getChannels, getArchivedChannels, joinChannel} from 'mattermost-redux/actions/channels'; -import {getOtherChannels, getChannelsInCurrentTeam} from 'mattermost-redux/selectors/entities/channels'; +import {getChannelsInCurrentTeam, getMyChannelMemberships} from 'mattermost-redux/selectors/entities/channels'; import {searchMoreChannels} from 'actions/channel_actions'; import {openModal, closeModal} from 'actions/views/modals'; @@ -24,11 +24,14 @@ import {ModalData} from 'types/actions'; import {GlobalState} from 'types/store'; import MoreChannels from './more_channels'; +import {makeGetGlobalItem} from 'selectors/storage'; +import Constants, {StoragePrefixes} from 'utils/constants'; +import {setGlobalItem} from 'actions/storage'; -const getNotArchivedOtherChannels = createSelector( - 'getNotArchivedOtherChannels', - getOtherChannels, - (channels: Channel[]) => channels && channels.filter((c) => c.delete_at === 0), +const getChannelsWithoutArchived = createSelector( + 'getChannelsWithoutArchived', + getChannelsInCurrentTeam, + (channels: Channel[]) => channels && channels.filter((c) => c.delete_at === 0 && c.type !== Constants.PRIVATE_CHANNEL), ); const getArchivedOtherChannels = createSelector( @@ -39,15 +42,18 @@ const getArchivedOtherChannels = createSelector( function mapStateToProps(state: GlobalState) { const team = getCurrentTeam(state) || {}; + const getGlobalItem = makeGetGlobalItem(StoragePrefixes.HIDE_JOINED_CHANNELS, 'false'); return { - channels: getNotArchivedOtherChannels(state) || [], + channels: getChannelsWithoutArchived(state) || [], archivedChannels: getArchivedOtherChannels(state) || [], currentUserId: getCurrentUserId(state), teamId: team.id, teamName: team.name, channelsRequestStarted: state.requests.channels.getChannels.status === RequestStatus.STARTED, canShowArchivedChannels: (getConfig(state).ExperimentalViewArchivedChannels === 'true'), + myChannelMemberships: getMyChannelMemberships(state) || {}, + shouldHideJoinedChannels: getGlobalItem(state) === 'true', rhsState: getRhsState(state), rhsOpen: getIsRhsOpen(state), }; @@ -60,6 +66,7 @@ type Actions = { searchMoreChannels: (term: string, shouldShowArchivedChannels: boolean) => Promise; openModal:

(modalData: ModalData

) => void; closeModal: (modalId: string) => void; + setGlobalItem: (name: string, value: string) => void; closeRightHandSide: () => void; } @@ -72,6 +79,7 @@ function mapDispatchToProps(dispatch: Dispatch) { searchMoreChannels, openModal, closeModal, + setGlobalItem, closeRightHandSide, }, dispatch), }; diff --git a/webapp/channels/src/components/more_channels/more_channels.scss b/webapp/channels/src/components/more_channels/more_channels.scss new file mode 100644 index 0000000000..56312debbe --- /dev/null +++ b/webapp/channels/src/components/more_channels/more_channels.scss @@ -0,0 +1,297 @@ +@charset 'UTF-8'; + +#moreChannelsModal { + .modal-content { + min-height: 600px; + max-height: calc(50vh - 240px); + } + + .modal-dialog { + margin-top: calc(45vh - 240px) !important; + } + + .filter-row--full { + position: relative; + padding: 0 32px; + + .input-clear { + top: 16px; + right: 46px; + } + + #searchIcon { + position: absolute; + top: 16px; + left: 46px; + color: rgba(var(--center-channel-color-rgb), 0.64); + pointer-events: none; + } + + #searchChannelsTextbox { + height: 48px; + padding-left: 40px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + box-shadow: none; + font-size: 16px; + + &::placeholder { + color: var(--center-channel-color); + } + + &:focus { + border: 2px solid var(--button-bg); + } + } + } + + .more-modal__dropdown { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 32px; + border-bottom: solid 1px rgba(var(--center-channel-color-rgb), 0.16); + margin: 0; + + span { + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 12px; + line-height: 16px; + } + + .MenuItem__primary-text { + width: 100%; + color: var(--center-channel-color); + font-size: 14px; + font-weight: 400; + line-height: 20px; + + svg { + margin-left: auto; + } + } + + .Menu__content { + border-color: rgba(var(--center-channel-color-rgb), 0.16); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + } + + #channelCountLabel { + color: var(--center-channel-color); + font-size: 12px; + font-weight: 400; + } + + #modalPreferenceContainer { + display: flex; + align-items: center; + justify-content: center; + + .get-app__checkbox { + display: flex; + width: 16px; + height: 16px; + align-items: center; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.24); + } + + #hideJoinedPreferenceCheckbox { + display: flex; + align-items: center; + cursor: pointer; + } + + #channelsMoreDropdown { + margin: 0 8px; + } + + #menuWrapper { + display: flex; + align-items: center; + justify-content: center; + padding: 4px 6px 4px 8px; + border: none; + background-color: unset; + } + + .MenuWrapper:hover { + background-color: rgba(var(--center-channel-color-rgb), 0.08); + border-radius: 4px; + } + + .MenuWrapper--open { + background-color: rgba(var(--button-bg-rgb), 0.12); + border-radius: 4px; + + &:hover { + background-color: rgba(var(--button-bg-rgb), 0.12); + } + } + } + } + + .modal-body { + padding: 15px 0 0; + + .filtered-user-list { + height: 500px; + } + + .more-modal__row { + padding: 0 32px; // todo check + border-bottom: none; + + .more-modal__details { + padding-left: 0; + color: rgba(var(--center-channel-color-rgb), 0.56); + + svg { + flex-shrink: 0; + } + + .more-modal__name { + align-items: center; + margin-top: 0; + + span { + color: var(--center-channel-color); + font-weight: 500; + } + } + + #channelPurposeContainer { + display: flex; + align-items: center; + justify-content: flex-start; + + .dot { + width: 3px; + height: 3px; + flex-shrink: 0; + background-color: rgba(var(--center-channel-color-rgb), 0.56); + border-radius: 50%; + } + + .more-modal__description { + margin-left: 4px; + font-weight: 400; + } + + #membershipIndicatorContainer { + display: flex; + align-items: center; + + span, + svg { + color: var(--online-indicator); + } + } + + span { + margin: 0 4px; + font-size: 12px; + font-weight: 600; + line-height: 12px; + opacity: 1; + } + } + } + + .more-modal__actions { + button { + display: none; + min-width: 54px; + height: 32px; + font-size: 12px; + font-weight: 600; + } + } + } + + .more-modal__row:hover, + .more-modal__row:focus { + background-color: rgba(var(--center-channel-color-rgb), 0.08); + cursor: pointer; + + .more-modal__actions { + .primaryButton, + .outlineButton { + display: inline-block; + } + } + } + + .form-group { + padding: 0 32px; + margin-bottom: 0; + } + + ::-webkit-scrollbar { + width: 4px; + } + + ::-webkit-scrollbar-track { + background: none; + } + } + + .modal-header { + .GenericModal__header { + display: flex; + width: 95%; + align-items: center; + justify-content: space-between; + padding-right: 4px; + } + + .close { + top: 22px; + } + } + + .outlineButton { + border: 1px solid var(--button-bg); + background: none; + border-radius: 4px; + color: var(--button-bg); + font-size: 12px; + font-weight: 600; + line-height: 16px; + } + + .outlineButton:hover { + background-color: rgba(var(--button-bg-rgb), 0.08); + } + + .filter-controls { + padding: 0; + + button { + min-width: 72px; + margin: 8px 32px; + } + } +} + +#moreChannelsList { + .primary-message { + margin-top: 8px; + color: var(--center-channel-color); + line-height: 28px; + } + + .secondary-message { + margin-bottom: 30px; + } + + .primaryButton { + background-color: var(--button-bg); + border-radius: 4px; + color: var(--button-color); + font-size: 14px; + font-weight: 600; + } + + #createNewChannelButton { + padding: 10px 20px; + } +} diff --git a/webapp/channels/src/components/more_channels/more_channels.test.tsx b/webapp/channels/src/components/more_channels/more_channels.test.tsx index a591bfef5b..aa0b9a1021 100644 --- a/webapp/channels/src/components/more_channels/more_channels.test.tsx +++ b/webapp/channels/src/components/more_channels/more_channels.test.tsx @@ -7,7 +7,7 @@ import {shallow} from 'enzyme'; import {ActionResult} from 'mattermost-redux/types/actions'; import MoreChannels, {Props} from 'components/more_channels/more_channels'; -import SearchableChannelList from 'components/searchable_channel_list.jsx'; +import SearchableChannelList from 'components/searchable_channel_list'; import {getHistory} from 'utils/browser_history'; import {TestHelper} from 'utils/test_helper'; @@ -73,6 +73,8 @@ describe('components/MoreChannels', () => { teamName: 'team_name', channelsRequestStarted: false, canShowArchivedChannels: true, + shouldHideJoinedChannels: false, + myChannelMemberships: {}, actions: { getChannels: jest.fn(), getArchivedChannels: jest.fn(), @@ -81,6 +83,7 @@ describe('components/MoreChannels', () => { openModal: jest.fn(), closeModal: jest.fn(), closeRightHandSide: jest.fn(), + setGlobalItem: jest.fn(), }, }; @@ -91,7 +94,6 @@ describe('components/MoreChannels', () => { expect(wrapper).toMatchSnapshot(); expect(wrapper.state('searchedChannels')).toEqual([]); - expect(wrapper.state('show')).toEqual(true); expect(wrapper.state('shouldShowArchivedChannels')).toEqual(false); expect(wrapper.state('search')).toEqual(false); expect(wrapper.state('serverError')).toBeNull(); @@ -102,16 +104,6 @@ describe('components/MoreChannels', () => { expect(wrapper.instance().props.actions.getChannels).toHaveBeenCalledWith(wrapper.instance().props.teamId, 0, 100); }); - test('should match state on handleHide', () => { - const wrapper = shallow( - , - ); - wrapper.setState({show: true}); - - wrapper.instance().handleHide(); - expect(wrapper.state('show')).toEqual(false); - }); - test('should call closeModal on handleExit', () => { const wrapper = shallow( , @@ -211,7 +203,6 @@ describe('components/MoreChannels', () => { process.nextTick(() => { expect(getHistory().push).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledTimes(1); - expect(wrapper.state('show')).toEqual(false); done(); }); }); @@ -249,7 +240,7 @@ describe('components/MoreChannels', () => { jest.runOnlyPendingTimers(); expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledTimes(1); - expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledWith('fail', false); + expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledWith('fail', false, false); process.nextTick(() => { expect(wrapper.state('search')).toEqual(true); expect(wrapper.state('searching')).toEqual(false); @@ -276,7 +267,7 @@ describe('components/MoreChannels', () => { jest.runOnlyPendingTimers(); expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledTimes(1); - expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledWith('channel', false); + expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledWith('channel', false, false); process.nextTick(() => { expect(wrapper.state('search')).toEqual(true); expect(wrapper.state('searching')).toEqual(false); @@ -303,7 +294,7 @@ describe('components/MoreChannels', () => { jest.runOnlyPendingTimers(); expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledTimes(1); - expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledWith('channel', true); + expect(wrapper.instance().props.actions.searchMoreChannels).toHaveBeenCalledWith('channel', true, false); process.nextTick(() => { expect(wrapper.state('search')).toEqual(true); expect(wrapper.state('searching')).toEqual(false); diff --git a/webapp/channels/src/components/more_channels/more_channels.tsx b/webapp/channels/src/components/more_channels/more_channels.tsx index de6db52c6a..6c797cc342 100644 --- a/webapp/channels/src/components/more_channels/more_channels.tsx +++ b/webapp/channels/src/components/more_channels/more_channels.tsx @@ -2,23 +2,29 @@ // See LICENSE.txt for license information. import React from 'react'; -import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; import {ActionResult} from 'mattermost-redux/types/actions'; -import {Channel} from '@mattermost/types/channels'; +import {RelationOneToOne} from '@mattermost/types/utilities'; +import {Channel, ChannelMembership} from '@mattermost/types/channels'; import Permissions from 'mattermost-redux/constants/permissions'; import NewChannelModal from 'components/new_channel_modal/new_channel_modal'; -import SearchableChannelList from 'components/searchable_channel_list.jsx'; +import SearchableChannelList from 'components/searchable_channel_list'; import TeamPermissionGate from 'components/permissions_gates/team_permission_gate'; import {ModalData} from 'types/actions'; import {RhsState} from 'types/store/rhs'; import {getHistory} from 'utils/browser_history'; -import {ModalIdentifiers, RHSStates} from 'utils/constants'; +import {ModalIdentifiers, RHSStates, StoragePrefixes} from 'utils/constants'; import {getRelativeChannelURL} from 'utils/url'; +import {GenericModal} from '@mattermost/components'; +import classNames from 'classnames'; +import {localizeMessage} from 'utils/utils'; +import LoadingScreen from 'components/loading_screen'; + +import './more_channels.scss'; const CHANNELS_CHUNK_SIZE = 50; const CHANNELS_PER_PAGE = 50; @@ -28,9 +34,14 @@ type Actions = { getChannels: (teamId: string, page: number, perPage: number) => void; getArchivedChannels: (teamId: string, page: number, channelsPerPage: number) => void; joinChannel: (currentUserId: string, teamId: string, channelId: string) => Promise; - searchMoreChannels: (term: string, shouldShowArchivedChannels: boolean) => Promise; + searchMoreChannels: (term: string, shouldShowArchivedChannels: boolean, shouldHideJoinedChannels: boolean) => Promise; openModal:

(modalData: ModalData

) => void; closeModal: (modalId: string) => void; + + /* + * Function to set a key-value pair in the local storage + */ + setGlobalItem: (name: string, value: string) => void; closeRightHandSide: () => void; } @@ -43,13 +54,15 @@ export type Props = { channelsRequestStarted?: boolean; canShowArchivedChannels?: boolean; morePublicChannelsModalType?: string; + myChannelMemberships: RelationOneToOne; + shouldHideJoinedChannels: boolean; rhsState?: RhsState; rhsOpen?: boolean; actions: Actions; } type State = { - show: boolean; + loading: boolean; shouldShowArchivedChannels: boolean; search: boolean; searchedChannels: Channel[]; @@ -60,6 +73,7 @@ type State = { export default class MoreChannels extends React.PureComponent { public searchTimeoutId: number; + activeChannels: Channel[] = []; constructor(props: Props) { super(props); @@ -67,7 +81,7 @@ export default class MoreChannels extends React.PureComponent { this.searchTimeoutId = 0; this.state = { - show: true, + loading: true, shouldShowArchivedChannels: this.props.morePublicChannelsModalType === 'private', search: false, searchedChannels: [], @@ -82,10 +96,11 @@ export default class MoreChannels extends React.PureComponent { if (this.props.canShowArchivedChannels) { this.props.actions.getArchivedChannels(this.props.teamId, 0, CHANNELS_CHUNK_SIZE * 2); } + this.loadComplete(); } - handleHide = () => { - this.setState({show: false}); + loadComplete = () => { + this.setState({loading: false}); }; handleNewChannel = () => { @@ -124,14 +139,17 @@ export default class MoreChannels extends React.PureComponent { handleJoin = async (channel: Channel, done: () => void) => { const {actions, currentUserId, teamId, teamName} = this.props; - const result = await actions.joinChannel(currentUserId, teamId, channel.id); + let result; - if (result.error) { + if (!this.isMemberOfChannel(channel.id)) { + result = await actions.joinChannel(currentUserId, teamId, channel.id); + } + + if (result?.error) { this.setState({serverError: result.error.message}); } else { getHistory().push(getRelativeChannelURL(teamName, channel.name)); this.closeEditRHS(); - this.handleHide(); } if (done) { @@ -153,7 +171,7 @@ export default class MoreChannels extends React.PureComponent { const searchTimeoutId = window.setTimeout( async () => { try { - const {data} = await this.props.actions.searchMoreChannels(term, this.state.shouldShowArchivedChannels); + const {data} = await this.props.actions.searchMoreChannels(term, this.state.shouldShowArchivedChannels, this.props.shouldHideJoinedChannels); if (searchTimeoutId !== this.searchTimeoutId) { return; } @@ -183,29 +201,47 @@ export default class MoreChannels extends React.PureComponent { this.setState({shouldShowArchivedChannels}); }; + isMemberOfChannel(channelId: string) { + return this.props.myChannelMemberships[channelId]; + } + + handleShowJoinedChannelsPreference = (shouldHideJoinedChannels: boolean) => { + // search again when switching channels to update search results + this.search(this.state.searchTerm); + this.props.actions.setGlobalItem(StoragePrefixes.HIDE_JOINED_CHANNELS, shouldHideJoinedChannels.toString()); + }; + + otherChannelsWithoutJoined = this.props.channels.filter((channel) => !this.isMemberOfChannel(channel.id)); + archivedChannelsWithoutJoined = this.props.archivedChannels.filter((channel) => !this.isMemberOfChannel(channel.id)); + render() { const { channels, archivedChannels, teamId, channelsRequestStarted, + shouldHideJoinedChannels, } = this.props; const { search, searchedChannels, serverError: serverErrorState, - show, searching, shouldShowArchivedChannels, } = this.state; - let activeChannels; + const otherChannelsWithoutJoined = channels.filter((channel) => !this.isMemberOfChannel(channel.id)); + const archivedChannelsWithoutJoined = archivedChannels.filter((channel) => !this.isMemberOfChannel(channel.id)); - if (shouldShowArchivedChannels) { - activeChannels = search ? searchedChannels : archivedChannels; + if (shouldShowArchivedChannels && shouldHideJoinedChannels) { + this.activeChannels = search ? searchedChannels : archivedChannelsWithoutJoined; + } else if (shouldShowArchivedChannels && !shouldHideJoinedChannels) { + this.activeChannels = search ? searchedChannels : archivedChannels; + } else if (!shouldShowArchivedChannels && shouldHideJoinedChannels) { + this.activeChannels = search ? searchedChannels : otherChannelsWithoutJoined; } else { - activeChannels = search ? searchedChannels : channels; + this.activeChannels = search ? searchedChannels : channels; } let serverError; @@ -214,87 +250,87 @@ export default class MoreChannels extends React.PureComponent {

; } - const createNewChannelButton = ( - - - - ); + + + ); + }; - const createChannelHelpText = ( - + const noResultsText = ( + <>

-
+ {createNewChannelButton('primaryButton', )} + ); - const body = ( + const body = this.state.loading ? : ( {serverError} ); + const title = ( + + ); + return ( - - - - - - {createNewChannelButton} - - - {body} - - + {body} + ); } } diff --git a/webapp/channels/src/components/searchable_channel_list.jsx b/webapp/channels/src/components/searchable_channel_list.jsx deleted file mode 100644 index fc66227ba7..0000000000 --- a/webapp/channels/src/components/searchable_channel_list.jsx +++ /dev/null @@ -1,319 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import PropTypes from 'prop-types'; -import React from 'react'; -import {FormattedMessage} from 'react-intl'; - -import {ArchiveOutlineIcon} from '@mattermost/compass-icons/components'; - -import LoadingScreen from 'components/loading_screen'; -import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; -import QuickInput from 'components/quick_input'; -import * as UserAgent from 'utils/user_agent'; -import {localizeMessage} from 'utils/utils'; -import LocalizedInput from 'components/localized_input/localized_input'; - -import SharedChannelIndicator from 'components/shared_channel_indicator'; - -import {t} from 'utils/i18n'; - -import MenuWrapper from './widgets/menu/menu_wrapper'; -import Menu from './widgets/menu/menu'; - -const NEXT_BUTTON_TIMEOUT_MILLISECONDS = 500; - -export default class SearchableChannelList extends React.PureComponent { - static getDerivedStateFromProps(props, state) { - return {isSearch: props.isSearch, page: props.isSearch && !state.isSearch ? 0 : state.page}; - } - - constructor(props) { - super(props); - - this.nextTimeoutId = 0; - - this.state = { - joiningChannel: '', - page: 0, - nextDisabled: false, - }; - - this.filter = React.createRef(); - this.channelListScroll = React.createRef(); - } - - componentDidMount() { - // only focus the search box on desktop so that we don't cause the keyboard to open on mobile - if (!UserAgent.isMobile() && this.filter.current) { - this.filter.current.focus(); - } - } - - handleJoin(channel) { - this.setState({joiningChannel: channel.id}); - this.props.handleJoin( - channel, - () => { - this.setState({joiningChannel: ''}); - }, - ); - } - - createChannelRow = (channel) => { - const ariaLabel = `${channel.display_name}, ${channel.purpose}`.toLowerCase(); - let archiveIcon; - let sharedIcon; - const {shouldShowArchivedChannels} = this.props; - - if (shouldShowArchivedChannels) { - archiveIcon = ( - - ); - } - - if (channel.shared) { - sharedIcon = ( - - ); - } - - return ( -
-
- -

{channel.purpose}

-
-
- -
-
- ); - }; - - nextPage = (e) => { - e.preventDefault(); - this.setState({page: this.state.page + 1, nextDisabled: true}); - this.nextTimeoutId = setTimeout(() => this.setState({nextDisabled: false}), NEXT_BUTTON_TIMEOUT_MILLISECONDS); - this.props.nextPage(this.state.page + 1); - this.channelListScroll.current?.scrollTo({top: 0}); - }; - - previousPage = (e) => { - e.preventDefault(); - this.setState({page: this.state.page - 1}); - this.channelListScroll.current?.scrollTo({top: 0}); - }; - - doSearch = () => { - const term = this.filter.current.value; - this.props.search(term); - if (term === '') { - this.setState({page: 0}); - } - }; - toggleArchivedChannelsOn = () => { - this.props.toggleArchivedChannels(true); - }; - toggleArchivedChannelsOff = () => { - this.props.toggleArchivedChannels(false); - }; - - render() { - const channels = this.props.channels; - let listContent; - let nextButton; - let previousButton; - - if (this.props.loading && channels.length === 0) { - listContent = ; - } else if (channels.length === 0) { - listContent = ( -
-

- -

- {this.props.noResultsText} -
- ); - } else { - const pageStart = this.state.page * this.props.channelsPerPage; - const pageEnd = pageStart + this.props.channelsPerPage; - const channelsToDisplay = this.props.channels.slice(pageStart, pageEnd); - listContent = channelsToDisplay.map(this.createChannelRow); - - if (channelsToDisplay.length >= this.props.channelsPerPage && pageEnd < this.props.channels.length) { - nextButton = ( - - ); - } - - if (this.state.page > 0) { - previousButton = ( - - ); - } - } - - let input = ( -
-
- -
-
- ); - - if (this.props.createChannelButton) { - input = ( -
-
- -
-
- {this.props.createChannelButton} -
-
- ); - } - - let channelDropdown; - - if (this.props.canShowArchivedChannels) { - channelDropdown = ( -
- - - - - - - -
- ); - } - - return ( -
- {input} - {channelDropdown} -
-
- {listContent} -
-
-
- {previousButton} - {nextButton} -
-
- ); - } -} - -SearchableChannelList.defaultProps = { - channels: [], - isSearch: false, -}; - -SearchableChannelList.propTypes = { - channels: PropTypes.arrayOf(PropTypes.object), - channelsPerPage: PropTypes.number, - nextPage: PropTypes.func.isRequired, - isSearch: PropTypes.bool, - search: PropTypes.func.isRequired, - handleJoin: PropTypes.func.isRequired, - noResultsText: PropTypes.object, - loading: PropTypes.bool, - createChannelButton: PropTypes.element, - toggleArchivedChannels: PropTypes.func.isRequired, - shouldShowArchivedChannels: PropTypes.bool.isRequired, - canShowArchivedChannels: PropTypes.bool.isRequired, -}; diff --git a/webapp/channels/src/components/searchable_channel_list.test.jsx b/webapp/channels/src/components/searchable_channel_list.test.jsx index 9ba5ddd4f8..1c5e58d10f 100644 --- a/webapp/channels/src/components/searchable_channel_list.test.jsx +++ b/webapp/channels/src/components/searchable_channel_list.test.jsx @@ -4,20 +4,24 @@ import React from 'react'; import {shallow} from 'enzyme'; -import SearchableChannelList from 'components/searchable_channel_list.jsx'; +import SearchableChannelList from 'components/searchable_channel_list'; describe('components/SearchableChannelList', () => { const baseProps = { channels: [], isSearch: false, channelsPerPage: 10, - nextPage: () => {}, // eslint-disable-line no-empty-function - search: () => {}, // eslint-disable-line no-empty-function - handleJoin: () => {}, // eslint-disable-line no-empty-function + nextPage: jest.fn(), + search: jest.fn(), + handleJoin: jest.fn(), loading: true, - toggleArchivedChannels: () => {}, // eslint-disable-line no-empty-function + toggleArchivedChannels: jest.fn(), + closeModal: jest.fn(), + hideJoinedChannelsPreference: jest.fn(), shouldShowArchivedChannels: false, canShowArchivedChannels: false, + rememberHideJoinedChannelsChecked: false, + noResultsText: <>{'no channel found'}, }; test('should match init snapshot', () => { diff --git a/webapp/channels/src/components/searchable_channel_list.tsx b/webapp/channels/src/components/searchable_channel_list.tsx new file mode 100644 index 0000000000..aa395d29ce --- /dev/null +++ b/webapp/channels/src/components/searchable_channel_list.tsx @@ -0,0 +1,481 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {ArchiveOutlineIcon, CheckIcon, ChevronDownIcon, GlobeIcon, LockOutlineIcon, MagnifyIcon} from '@mattermost/compass-icons/components'; +import {Channel, ChannelMembership} from '@mattermost/types/channels'; +import {RelationOneToOne} from '@mattermost/types/utilities'; +import {isPrivateChannel} from 'mattermost-redux/utils/channel_utils'; + +import classNames from 'classnames'; + +import LoadingScreen from 'components/loading_screen'; +import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; +import QuickInput from 'components/quick_input'; +import CheckboxCheckedIcon from 'components/widgets/icons/checkbox_checked_icon'; +import LocalizedInput from 'components/localized_input/localized_input'; +import MagnifyingGlassSVG from 'components/common/svg_images_components/magnifying_glass_svg'; + +import * as UserAgent from 'utils/user_agent'; +import Constants, {ModalIdentifiers} from 'utils/constants'; +import {localizeMessage, localizeAndFormatMessage} from 'utils/utils'; +import {isArchivedChannel} from 'utils/channel_utils'; + +import {t} from 'utils/i18n'; +import MenuWrapper from './widgets/menu/menu_wrapper'; +import Menu from './widgets/menu/menu'; +import {isKeyPressed} from 'utils/keyboard'; + +const NEXT_BUTTON_TIMEOUT_MILLISECONDS = 500; + +type Props = { + channels: Channel[]; + channelsPerPage: number; + nextPage: (page: number) => void; + isSearch: boolean; + search: (term: string) => void; + handleJoin: (channel: Channel, done: () => void) => void; + noResultsText: JSX.Element; + toggleArchivedChannels: (shouldShowArchivedChannels: boolean) => void; + shouldShowArchivedChannels: boolean; + myChannelMemberships: RelationOneToOne; + closeModal: (modalId: string) => void; + hideJoinedChannelsPreference: (shouldHideJoinedChannels: boolean) => void; + rememberHideJoinedChannelsChecked: boolean; + canShowArchivedChannels?: boolean; + loading?: boolean; +} + +type State = { + joiningChannel: string; + page: number; + nextDisabled: boolean; + channelSearchValue: string; + isSearch?: boolean; +} + +export default class SearchableChannelList extends React.PureComponent { + private nextTimeoutId: number | NodeJS.Timeout; + private filter: React.RefObject; + private channelListScroll: React.RefObject; + + static getDerivedStateFromProps(props: Props, state: State) { + return {isSearch: props.isSearch, page: props.isSearch && !state.isSearch ? 0 : state.page}; + } + + constructor(props: Props) { + super(props); + + this.nextTimeoutId = 0; + + this.state = { + joiningChannel: '', + page: 0, + nextDisabled: false, + channelSearchValue: '', + }; + + this.filter = React.createRef(); + this.channelListScroll = React.createRef(); + } + + componentDidMount() { + // only focus the search box on desktop so that we don't cause the keyboard to open on mobile + if (!UserAgent.isMobile() && this.filter.current) { + this.filter.current.focus(); + } + document.addEventListener('keydown', this.onKeyDown); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.onKeyDown); + } + + onKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + const isEnterKeyPressed = isKeyPressed(e, Constants.KeyCodes.ENTER); + if (isEnterKeyPressed && (e.shiftKey || e.ctrlKey || e.altKey)) { + return; + } + if (isEnterKeyPressed && target?.classList.contains('more-modal__row')) { + target.click(); + } + }; + + handleJoin = (channel: Channel, e: React.MouseEvent) => { + e.stopPropagation(); + this.setState({joiningChannel: channel.id}); + this.props.handleJoin( + channel, + () => { + this.setState({joiningChannel: ''}); + }, + ); + if (this.isMemberOfChannel(channel.id)) { + this.props.closeModal(ModalIdentifiers.MORE_CHANNELS); + } + }; + + isMemberOfChannel(channelId: string) { + return this.props.myChannelMemberships[channelId]; + } + + 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 membershipIndicator = this.isMemberOfChannel(channel.id) ? ( +
+ + +
+ ) : null; + + const channelPurposeContainerAriaLabel = localizeAndFormatMessage( + t('more_channels.channel_purpose'), + 'Channel Information: Membership Indicator: Joined, Purpose: {channelPurpose}', + {channelPurpose: channel.purpose || ''}, + ); + + const channelPurposeContainer = ( +
+ {membershipIndicator} + {(channel.purpose.length > 0 && membershipIndicator) ? : null} + {channel.purpose} +
+ ); + + const joinViewChannelButtonClass = classNames('btn', { + outlineButton: this.isMemberOfChannel(channel.id), + primaryButton: !this.isMemberOfChannel(channel.id), + }); + + const joinViewChannelButton = ( + + ); + + return ( +
this.handleJoin(channel, e)} + tabIndex={0} + > +
+
+ {channelTypeIcon} + {channel.display_name} +
+ {channelPurposeContainer} +
+
+ {joinViewChannelButton} +
+
+ ); + }; + + nextPage = (e: React.MouseEvent) => { + e.preventDefault(); + this.setState({page: this.state.page + 1, nextDisabled: true}); + this.nextTimeoutId = setTimeout(() => this.setState({nextDisabled: false}), NEXT_BUTTON_TIMEOUT_MILLISECONDS); + this.props.nextPage(this.state.page + 1); + this.channelListScroll.current?.scrollTo({top: 0}); + }; + + previousPage = (e: React.MouseEvent) => { + e.preventDefault(); + this.setState({page: this.state.page - 1}); + this.channelListScroll.current?.scrollTo({top: 0}); + }; + + doSearch = () => { + this.props.search(this.state.channelSearchValue); + if (this.state.channelSearchValue === '') { + this.setState({page: 0}); + } + }; + handleChange = (e?: React.FormEvent) => { + if (e?.currentTarget) { + this.setState({channelSearchValue: e?.currentTarget.value}, () => this.doSearch()); + } + }; + handleClear = () => { + this.setState({channelSearchValue: ''}, () => this.doSearch()); + }; + toggleArchivedChannelsOn = () => { + this.props.toggleArchivedChannels(true); + }; + toggleArchivedChannelsOff = () => { + this.props.toggleArchivedChannels(false); + }; + handleChecked = () => { + // If it was checked, and now we're unchecking it, clear the preference + if (this.props.rememberHideJoinedChannelsChecked) { + this.props.hideJoinedChannelsPreference(false); + } else { + this.props.hideJoinedChannelsPreference(true); + } + }; + + render() { + const channels = this.props.channels; + let listContent; + let nextButton; + let previousButton; + + let emptyStateMessage = ( + + ); + + if (this.state.channelSearchValue.length > 0) { + emptyStateMessage = ( + + ); + } + + if (this.props.loading && channels.length === 0) { + listContent = ; + } else if (channels.length === 0) { + listContent = ( +
0 ? + localizeAndFormatMessage(t('more_channels.noMore'), 'No results for {text}', {text: this.state.channelSearchValue}) : + localizeMessage('widgets.channels_input.empty', 'No channels found') + } + > + +

+ {emptyStateMessage} +

+ {this.props.noResultsText} +
+ ); + } else { + const pageStart = this.state.page * this.props.channelsPerPage; + const pageEnd = pageStart + this.props.channelsPerPage; + const channelsToDisplay = this.props.channels.slice(pageStart, pageEnd); + listContent = channelsToDisplay.map(this.createChannelRow); + + if (channelsToDisplay.length >= this.props.channelsPerPage && pageEnd < this.props.channels.length) { + nextButton = ( + + ); + } + + if (this.state.page > 0) { + previousButton = ( + + ); + } + } + + const input = ( +
+ + +
+ ); + + let channelDropdown; + let checkIcon; + + if (this.props.canShowArchivedChannels) { + checkIcon = ( + + ); + channelDropdown = ( + + + +
+ } + text={localizeMessage('suggestion.search.public', 'Public Channels')} + rightDecorator={this.props.shouldShowArchivedChannels ? null : checkIcon} + ariaLabel={localizeMessage('suggestion.search.public', 'Public Channels')} + /> +
+ } + text={localizeMessage('suggestion.archive', 'Archived Channels')} + rightDecorator={this.props.shouldShowArchivedChannels ? checkIcon : null} + ariaLabel={localizeMessage('suggestion.archive', 'Archived Channels')} + /> +
+
+ ); + } + + const hideJoinedButtonClass = classNames('get-app__checkbox', {checked: this.props.rememberHideJoinedChannelsChecked}); + const hideJoinedPreferenceCheckbox = ( +
+ + +
+ ); + + let channelCountLabel; + if (channels.length === 0) { + channelCountLabel = localizeMessage('more_channels.count_zero', '0 Results'); + } else if (channels.length === 1) { + channelCountLabel = localizeMessage('more_channels.count_one', '1 Result'); + } else if (channels.length > 1) { + channelCountLabel = localizeAndFormatMessage(t('more_channels.count'), '0 Results', {count: channels.length}); + } else { + channelCountLabel = localizeMessage('more_channels.count_zero', '0 Results'); + } + + const dropDownContainer = ( +
+ {channelCountLabel} +
+ {channelDropdown} + {hideJoinedPreferenceCheckbox} +
+
+ ); + + return ( +
+ {input} + {dropDownContainer} +
+
+ {listContent} +
+
+
+ {previousButton} + {nextButton} +
+
+ ); + } +} + +// SearchableChannelList.defaultProps = { +// channels: [], +// isSearch: false, +// }; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 9b72fe0858..227cb88032 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4096,16 +4096,25 @@ "modal.manual_status.title_dnd": "Your Status is Set to \"Do Not Disturb\"", "modal.manual_status.title_offline": "Your Status is Set to \"Offline\"", "modal.manual_status.title_ooo": "Your Status is Set to \"Out of Office\"", + "more_channels.channel_purpose": "Channel Information: Membership Indicator: Joined, Member count {memberCount} , Purpose: {channelPurpose}", + "more_channels.count": "{count} Results", + "more_channels.count_one": "1 Result", + "more_channels.count_zero": "0 Results", "more_channels.create": "Create New Channel", - "more_channels.createClick": "Click 'Create New Channel' to make a new one", - "more_channels.join": "Join", - "more_channels.joining": "Joining...", + "more_channels.hide_joined": "Hide Joined", + "more_channels.hide_joined_checked": "Hide joined channels checkbox, checked", + "more_channels.hide_joined_not_checked": "Hide joined channels checkbox, not checked", + "more_channels.joined": "Joined", + "more_channels.membership_indicator": "Membership Indicator: Joined", "more_channels.next": "Next", - "more_channels.noMore": "No more channels to join", + "more_channels.noArchived": "No archived channels", + "more_channels.noMore": "No results for \"{text}\"", + "more_channels.noPublic": "No public channels", "more_channels.prev": "Previous", + "more_channels.searchError": "Try searching different keywords, checking for typos or adjusting the filters.", "more_channels.show_archived_channels": "Channel Type: Archived", "more_channels.show_public_channels": "Channel Type: Public", - "more_channels.title": "More Channels", + "more_channels.title": "Browse Channels", "more_channels.view": "View", "more_direct_channels.directchannel.deactivated": "{displayname} - Deactivated", "more_direct_channels.directchannel.you": "{displayname} (you)", diff --git a/webapp/channels/src/sass/components/_channel-invite-modal.scss b/webapp/channels/src/sass/components/_channel-invite-modal.scss index f1893c9a76..d3e70e8398 100644 --- a/webapp/channels/src/sass/components/_channel-invite-modal.scss +++ b/webapp/channels/src/sass/components/_channel-invite-modal.scss @@ -71,7 +71,9 @@ .primary-message { margin: 0; + color: var(--center-channel-color); font-size: inherit; + line-height: 28px; } } diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index f3aa701f3d..686ee962eb 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -904,6 +904,7 @@ export const StoragePrefixes = { CHANNEL_CATEGORY_COLLAPSED: 'channelCategoryCollapsed_', INLINE_IMAGE_VISIBLE: 'isInlineImageVisible_', DELINQUENCY: 'delinquency_', + HIDE_JOINED_CHANNELS: 'hideJoinedChannels', }; export const LandingPreferenceTypes = { diff --git a/webapp/platform/components/src/generic_modal/generic_modal.tsx b/webapp/platform/components/src/generic_modal/generic_modal.tsx index 11521ea565..7e65fbfdc2 100644 --- a/webapp/platform/components/src/generic_modal/generic_modal.tsx +++ b/webapp/platform/components/src/generic_modal/generic_modal.tsx @@ -47,6 +47,7 @@ export type Props = { bodyPadding?: boolean; footerContent?: React.ReactNode; footerDivider?: boolean; + headerButton?: React.ReactNode; }; type State = { @@ -173,6 +174,7 @@ export class GenericModal extends React.PureComponent {

{this.props.modalHeaderText}

+ {this.props.headerButton}
);