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 new file mode 100644 index 00000000000..0e152a41b66 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/channels/browse_channels_modal.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Locator, expect} from '@playwright/test'; + +export default class BrowseChannelsModal { + readonly container: Locator; + + readonly createNewChannelButton: Locator; + readonly hideJoinedCheckbox: Locator; + readonly searchInput: Locator; + + readonly results: Locator; + + constructor(container: Locator) { + this.container = container; + + this.createNewChannelButton = container.getByRole('button', {name: 'Create New Channel'}); + this.hideJoinedCheckbox = container.getByRole('checkbox', {name: 'Hide Joined'}); + this.searchInput = container.getByRole('textbox', {name: 'Search channels'}); + + // This role seems incorrect and will likely need to be changed later + this.results = this.container.getByRole('search'); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } + + async toBeDoneLoading() { + await expect(this.container.locator('.loading-screen')).toHaveCount(0); + } + + async toHaveNResults(count: number) { + await expect(this.results.locator('.more-modal__row')).toHaveCount(count); + } + + async fillSearchInput(text: string) { + await this.searchInput.fill(text); + } + + async toHaveChannelAsNthResult(channelName: string, index: number) { + const row = this.results.locator('.more-modal__row').nth(index); + + expect(await row.getAttribute('data-testid')).toEqual(`ChannelRow-${channelName}`); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/index.ts b/e2e-tests/playwright/lib/src/ui/components/index.ts index b45e7bdce0e..383fa93c381 100644 --- a/e2e-tests/playwright/lib/src/ui/components/index.ts +++ b/e2e-tests/playwright/lib/src/ui/components/index.ts @@ -7,6 +7,7 @@ import GlobalHeader from './global_header'; import MainHeader from './main_header'; import UserAccountMenu from './user_account_menu'; // Channels Components +import BrowseChannelsModal from './channels/browse_channels_modal'; import ChannelsAppBar from './channels/app_bar'; import ChannelsCenterView from './channels/center_view'; import CreateTeamForm from './channels/create_team_form'; @@ -88,6 +89,7 @@ const components = { FindChannelsModal, FlagPostConfirmationDialog, NewChannelModal, + BrowseChannelsModal, GenericConfirmModal, InvitePeopleModal, MembersInvitedModal, @@ -158,6 +160,7 @@ export { FindChannelsModal, FlagPostConfirmationDialog, NewChannelModal, + BrowseChannelsModal, GenericConfirmModal, InvitePeopleModal, MembersInvitedModal, diff --git a/e2e-tests/playwright/lib/src/ui/pages/channels.ts b/e2e-tests/playwright/lib/src/ui/pages/channels.ts index 3f5f6ba8dfc..280bbbfd843 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/channels.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/channels.ts @@ -5,6 +5,7 @@ import {expect, Page} from '@playwright/test'; import {waitUntil} from 'async-wait-until'; import { + BrowseChannelsModal, ChannelsPost, ChannelSettingsModal, CreateTeamForm, @@ -36,6 +37,7 @@ export default class ChannelsPage { readonly deletePostModal; readonly findChannelsModal; readonly newChannelModal; + readonly browseChannelsModal; public invitePeopleModal: InvitePeopleModal | undefined; public membersInvitedModal: MembersInvitedModal | undefined; readonly profileModal; @@ -72,7 +74,8 @@ export default class ChannelsPage { this.createTeamForm = new CreateTeamForm(page.locator('.signup-team__container')); this.deletePostModal = new components.DeletePostModal(page.locator('#deletePostModal')); this.findChannelsModal = new components.FindChannelsModal(page.getByRole('dialog', {name: 'Find Channels'})); - this.newChannelModal = new NewChannelModal(page.locator('#new-channel-modal')); + this.newChannelModal = new NewChannelModal(page.getByRole('dialog', {name: 'Create a new channel'})); + this.browseChannelsModal = new BrowseChannelsModal(page.getByRole('dialog', {name: 'Browse Channels'})); this.profileModal = new components.ProfileModal(page.getByRole('dialog', {name: 'Profile'})); this.settingsModal = new components.SettingsModal(page.getByRole('dialog', {name: 'Settings'})); this.teamSettingsModal = new components.TeamSettingsModal(page.getByRole('dialog', {name: 'Team Settings'})); @@ -208,12 +211,20 @@ export default class ChannelsPage { async openNewChannelModal(): Promise { await this.sidebarLeft.browseOrCreateChannelButton.click(); - await this.page.locator('#createNewChannelMenuItem').click(); + await this.page.getByText('Create new channel').click(); await this.newChannelModal.toBeVisible(); return this.newChannelModal; } + async openBrowseChannelsModal(): Promise { + await this.sidebarLeft.browseOrCreateChannelButton.click(); + await this.page.getByText('Browse channels').click(); + await this.browseChannelsModal.toBeVisible(); + + return this.browseChannelsModal; + } + async openCreateTeamForm(): Promise { await this.sidebarLeft.teamMenuButton.click(); await this.teamMenu.toBeVisible(); 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 2c3c890f185..2583ecbab75 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 @@ -40,32 +40,22 @@ test( await channelsPage.goto(team.name, 'town-square'); await channelsPage.toBeVisible(); - // # Click on Browse or Create Channel button and then Browse Channels - await channelsPage.sidebarLeft.browseOrCreateChannelButton.click(); - const browseChannelsMenuItem = page.locator('#browseChannelsMenuItem'); - await browseChannelsMenuItem.click(); - - // * Verify the Browse Channels dialog is visible - const dialog = page.getByRole('dialog', {name: 'Browse Channels'}); - await expect(dialog).toBeVisible(); - - // * Verify the heading - await expect(dialog.getByRole('heading', {name: 'Browse Channels'})).toBeVisible(); + // # Open the Browse Channels modal + const dialog = await channelsPage.openBrowseChannelsModal(); // * Verify the search input exists - const searchInput = dialog.getByPlaceholder('Search channels'); + const searchInput = dialog.searchInput; await expect(searchInput).toBeVisible(); // # Wait for channel list to load - const channelList = dialog.locator('#moreChannelsList'); - await expect(channelList).toBeVisible(); + await dialog.toBeDoneLoading(); // # Hide already joined channels - const hideJoinedCheckbox = dialog.getByText('Hide Joined'); + const hideJoinedCheckbox = dialog.hideJoinedCheckbox; await hideJoinedCheckbox.click(); // # Focus on Create Channel button and tab through elements - const createChannelButton = dialog.locator('#createNewChannelButton'); + const createChannelButton = dialog.createNewChannelButton; await createChannelButton.focus(); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); @@ -74,8 +64,9 @@ test( await page.keyboard.press('Tab'); // * Verify channel name is highlighted and has proper aria-label + await dialog.toHaveChannelAsNthResult(channel1.name, 0); const channel1AriaLabel = `${channel1.display_name.toLowerCase()}, ${channel1.purpose.toLowerCase()}`; - const channel1Item = dialog.getByLabel(channel1AriaLabel); + const channel1Item = dialog.container.getByLabel(channel1AriaLabel); await expect(channel1Item).toBeVisible(); await expect(channel1Item).toBeFocused(); @@ -83,8 +74,9 @@ test( await page.keyboard.press('Tab'); // * Verify focus moved to next channel + await dialog.toHaveChannelAsNthResult(channel2.name, 1); const channel2AriaLabel = `${channel2.display_name.toLowerCase()}, ${channel2.purpose.toLowerCase()}`; - const channel2Item = dialog.getByLabel(channel2AriaLabel); + const channel2Item = dialog.container.getByLabel(channel2AriaLabel); await expect(channel2Item).toBeFocused(); }, ); @@ -109,18 +101,11 @@ test( await channelsPage.goto(team.name, 'town-square'); await channelsPage.toBeVisible(); - // # Click on Browse or Create Channel button and then Browse Channels - await channelsPage.sidebarLeft.browseOrCreateChannelButton.click(); - const browseChannelsMenuItem = page.locator('#browseChannelsMenuItem'); - await browseChannelsMenuItem.click(); - - // * Verify the Browse Channels dialog is visible - const dialog = page.getByRole('dialog', {name: 'Browse Channels'}); - await expect(dialog).toBeVisible(); - await pw.wait(pw.duration.one_sec); + // # Open the Browse Channels modal + const dialog = await channelsPage.openBrowseChannelsModal(); // * Verify aria snapshot of Browse Channels dialog - await expect(dialog).toMatchAriaSnapshot(` + await expect(dialog.container).toMatchAriaSnapshot(` - dialog "Browse Channels": - document: - heading "Browse Channels" [level=1] diff --git a/e2e-tests/playwright/specs/functional/channels/post_textbox/channel_autocomplete_sorting.spec.ts b/e2e-tests/playwright/specs/functional/channels/post_textbox/channel_autocomplete_sorting.spec.ts new file mode 100644 index 00000000000..4dd6d7ed7f5 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/post_textbox/channel_autocomplete_sorting.spec.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify that the "Other Channels" group in the ~channel autocomplete in the message input prioritizes + * channels whose DisplayName matches the search term. + */ +test( + 'MM-67953 channel mention autocomplete prioritizes DisplayName matches in Other Channels', + {tag: ['@mentions']}, + async ({pw}) => { + // # Initialize setup + const {team, user, adminClient} = await pw.initSetup(); + + // # Create channels whose DisplayName matches the search term (user is NOT a member) + await adminClient.createChannel({ + team_id: team.id, + name: 'ac-gamma-conversation-' + Date.now(), + display_name: 'Gamma: Conversation', + type: 'O', + }); + await adminClient.createChannel({ + team_id: team.id, + name: 'ac-gamma-logs-' + Date.now(), + display_name: 'Gamma: Logs', + type: 'O', + }); + + // # Create channels whose Purpose matches but DisplayName does NOT (user is NOT a member) + await adminClient.createChannel({ + team_id: team.id, + name: 'ac-alpha-channel-' + Date.now(), + display_name: 'Alpha Channel', + type: 'O', + purpose: 'alpha release of gamma', + }); + await adminClient.createChannel({ + team_id: team.id, + name: 'ac-beta-channel-' + Date.now(), + display_name: 'Beta Channel', + type: 'O', + purpose: 'beta release of gamma', + }); + + // # Log in as regular user + const {channelsPage} = await pw.testBrowser.login(user); + + // # Visit town-square channel + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Type a channel mention in the message input to trigger autocomplete + await channelsPage.centerView.postCreate.writeMessage('~gamma'); + + // # Wait for the suggestion list to appear + const suggestionList = channelsPage.centerView.postCreate.suggestionList; + await expect(suggestionList).toBeVisible(); + + // # Get all suggestion items within the "Other Channels" group + const otherChannelsGroup = suggestionList.getByRole('group', {name: 'Other Channels'}); + await expect(otherChannelsGroup).toBeVisible(); + + const suggestions = otherChannelsGroup.getByRole('option'); + + // * Verify at least 4 suggestions are shown in "Other Channels" + await expect(suggestions).toHaveCount(4); + + // * Verify DisplayName-matching channels appear first, sorted alphabetically + await expect(suggestions.nth(0)).toContainText('Gamma: Conversation'); + await expect(suggestions.nth(1)).toContainText('Gamma: Logs'); + + // * Verify Purpose-only-matching channels appear after, sorted alphabetically by DisplayName + await expect(suggestions.nth(2)).toContainText('Alpha Channel'); + await expect(suggestions.nth(3)).toContainText('Beta Channel'); + }, +); diff --git a/e2e-tests/playwright/specs/functional/channels/search/browse_channels_sorting.spec.ts b/e2e-tests/playwright/specs/functional/channels/search/browse_channels_sorting.spec.ts new file mode 100644 index 00000000000..c1ef63746b8 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/search/browse_channels_sorting.spec.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify that Browse Channels modal search results prioritize channels whose DisplayName matches the search + * term over channels that only match on other fields. + */ +test( + 'MM-67953 Browse Channels modal prioritizes DisplayName matches in search results', + {tag: ['@browse_channels']}, + async ({pw}) => { + // # Initialize setup + const {team, user, adminClient} = await pw.initSetup(); + + // # Create channels whose DisplayName matches the search term + const displayMatchA = await adminClient.createChannel({ + team_id: team.id, + name: 'ac-gamma-conversation-' + Date.now(), + display_name: 'Gamma: Conversation', + type: 'O', + }); + const displayMatchB = await adminClient.createChannel({ + team_id: team.id, + name: 'ac-gamma-logs-' + Date.now(), + display_name: 'Gamma: Logs', + type: 'O', + }); + + // # Create channels whose Purpose matches but DisplayName does NOT + const purposeMatchA = await adminClient.createChannel({ + team_id: team.id, + name: 'ac-alpha-channel-' + Date.now(), + display_name: 'Alpha Channel', + type: 'O', + purpose: 'alpha release of gamma', + }); + const purposeMatchB = await adminClient.createChannel({ + team_id: team.id, + name: 'ac-beta-channel-' + Date.now(), + display_name: 'Beta Channel', + type: 'O', + purpose: 'beta release of gamma', + }); + + // # Log in as regular user + const {channelsPage} = await pw.testBrowser.login(user); + + // # Visit town-square channel + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Open the Browse Channels modal + const dialog = await channelsPage.openBrowseChannelsModal(); + await dialog.toBeVisible(); + + // # Search for the term that matches DisplayName on some channels and Purpose on others + await dialog.fillSearchInput('gamma'); + await dialog.toBeDoneLoading(); + await dialog.toHaveNResults(4); + + // DisplayName matches come first, sorted alphabetically + await dialog.toHaveChannelAsNthResult(displayMatchA.name, 0); + await dialog.toHaveChannelAsNthResult(displayMatchB.name, 1); + + // Purpose-only matches come after, sorted alphabetically by DisplayName + await dialog.toHaveChannelAsNthResult(purposeMatchA.name, 2); + await dialog.toHaveChannelAsNthResult(purposeMatchB.name, 3); + }, +); diff --git a/server/channels/store/sqlstore/channel_store.go b/server/channels/store/sqlstore/channel_store.go index 507f9a41c69..10730038f65 100644 --- a/server/channels/store/sqlstore/channel_store.go +++ b/server/channels/store/sqlstore/channel_store.go @@ -3036,9 +3036,7 @@ func (s SqlChannelStore) Autocomplete(rctx request.CTX, userID, term string, inc sq.Expr("c.TeamId = t.id"), sq.Expr("t.id = tm.TeamId"), sq.Eq{"tm.UserId": userID}, - }). - OrderBy("c.DisplayName"). - Limit(model.ChannelSearchDefaultLimit) + }) // Always filter out soft-deleted team memberships - users removed from // a team should not see channels from that team regardless of includeDeleted @@ -3069,6 +3067,10 @@ func (s SqlChannelStore) Autocomplete(rctx request.CTX, userID, term string, inc query = query.Where(searchClause) } + query = orderByDisplayNameMatch(query, term) + + query = query.Limit(model.ChannelSearchDefaultLimit) + sql, args, err := query.ToSql() if err != nil { return nil, errors.Wrap(err, "Autocomplete_Tosql") @@ -3086,7 +3088,6 @@ func (s SqlChannelStore) AutocompleteInTeam(rctx request.CTX, teamID, userID, te query := s.getQueryBuilder().Select(channelSliceColumns(true, "c")...). From("Channels c"). Where(sq.Eq{"c.TeamId": teamID}). - OrderBy("c.DisplayName"). Limit(model.ChannelSearchDefaultLimit) if !includeDeleted { @@ -3114,6 +3115,8 @@ func (s SqlChannelStore) AutocompleteInTeam(rctx request.CTX, teamID, userID, te query = query.Where(searchClause) } + query = orderByDisplayNameMatch(query, term) + return s.performSearch(query, term) } @@ -3583,6 +3586,18 @@ func (s SqlChannelStore) searchClause(term string) sq.Sqlizer { } } +// orderByDisplayNameMatch adds an ORDER BY clause that prioritizes channels whose DisplayName matches the search term, +// then sorts alphabetically by DisplayName. +func orderByDisplayNameMatch(query sq.SelectBuilder, term string) sq.SelectBuilder { + sanitizedTerm := sanitizeSearchTerm(term, "*") + if sanitizedTerm == "" { + return query.OrderBy("c.DisplayName") + } + + likeTerm := wildcardSearchTerm(sanitizedTerm) + return query.OrderByClause("CASE WHEN LOWER(c.DisplayName) LIKE LOWER(?) ESCAPE '*' THEN 0 ELSE 1 END, c.DisplayName", likeTerm) +} + func (s SqlChannelStore) searchGroupChannelsQuery(userId, term string) sq.SelectBuilder { baseLikeTerm := "ARRAY_TO_STRING(ARRAY_AGG(u.Username), ', ') LIKE ?" terms := strings.Fields((strings.ToLower(term))) diff --git a/server/channels/store/storetest/channel_store.go b/server/channels/store/storetest/channel_store.go index 7631e03842b..bfa7fc8c76d 100644 --- a/server/channels/store/storetest/channel_store.go +++ b/server/channels/store/storetest/channel_store.go @@ -120,7 +120,7 @@ func TestChannelStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore t.Run("GetMemberCountsByGroup", func(t *testing.T) { testGetMemberCountsByGroup(t, rctx, ss) }) t.Run("GetGuestCount", func(t *testing.T) { testGetGuestCount(t, rctx, ss) }) t.Run("SearchMore", func(t *testing.T) { testChannelStoreSearchMore(t, rctx, ss) }) - t.Run("SearchInTeam", func(t *testing.T) { testChannelStoreSearchInTeam(t, rctx, ss) }) + t.Run("SearchInTeam", func(t *testing.T) { testChannelStoreSearchInTeam(t, rctx, ss, s) }) t.Run("Autocomplete", func(t *testing.T) { testAutocomplete(t, rctx, ss, s) }) t.Run("SearchForUserInTeam", func(t *testing.T) { testChannelStoreSearchForUserInTeam(t, rctx, ss) }) t.Run("SearchAllChannels", func(t *testing.T) { testChannelStoreSearchAllChannels(t, rctx, ss) }) @@ -5990,7 +5990,7 @@ func testChannelStoreSearchMore(t *testing.T, rctx request.CTX, ss store.Store) }) } -func testChannelStoreSearchInTeam(t *testing.T, rctx request.CTX, ss store.Store) { +func testChannelStoreSearchInTeam(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { teamID := model.NewId() otherTeamID := model.NewId() @@ -6206,6 +6206,138 @@ func testChannelStoreSearchInTeam(t *testing.T, rctx request.CTX, ss store.Store require.ElementsMatch(t, testCase.ExpectedResults, channels) }) } + + t.Run("AutoCompleteInTeam/DisplayName matches are prioritized in ordering", func(t *testing.T) { + s.GetMaster().Exec("TRUNCATE Channels") + + sortTeam := &model.Team{ + DisplayName: "sort-team", + Name: NewTestID(), + Email: MakeEmail(), + Type: model.TeamOpen, + } + sortTeam, err := ss.Team().Save(sortTeam) + require.NoError(t, err) + + sortUser := model.NewId() + _, err = ss.Team().SaveMember(rctx, &model.TeamMember{TeamId: sortTeam.Id, UserId: sortUser}, -1) + require.NoError(t, err) + + // Channels where DisplayName matches "alpha": + chDisplayAlpha2 := model.Channel{TeamId: sortTeam.Id, DisplayName: "Alpha Two", Name: NewTestID(), Type: model.ChannelTypeOpen} + _, err = ss.Channel().Save(rctx, &chDisplayAlpha2, -1) + require.NoError(t, err) + + chDisplayAlpha1 := model.Channel{TeamId: sortTeam.Id, DisplayName: "Alpha One", Name: NewTestID(), Type: model.ChannelTypeOpen} + _, err = ss.Channel().Save(rctx, &chDisplayAlpha1, -1) + require.NoError(t, err) + + // Channels where only Purpose matches "alpha" (DisplayName does NOT contain "alpha"): + chPurposeOnly2 := model.Channel{TeamId: sortTeam.Id, DisplayName: "Zulu Channel", Name: NewTestID(), Purpose: "alpha related work", Type: model.ChannelTypeOpen} + _, err = ss.Channel().Save(rctx, &chPurposeOnly2, -1) + require.NoError(t, err) + + chPurposeOnly1 := model.Channel{TeamId: sortTeam.Id, DisplayName: "Bravo Channel", Name: NewTestID(), Purpose: "alpha discussions", Type: model.ChannelTypeOpen} + _, err = ss.Channel().Save(rctx, &chPurposeOnly1, -1) + require.NoError(t, err) + + channels, err := ss.Channel().AutocompleteInTeam(rctx, sortTeam.Id, sortUser, "alpha", false, false) + require.NoError(t, err) + require.Len(t, channels, 4) + + // DisplayName matches come first, sorted alphabetically by DisplayName + assert.Equal(t, chDisplayAlpha1.Id, channels[0].Id, "first should be Alpha One (DisplayName match, alphabetically first)") + assert.Equal(t, chDisplayAlpha2.Id, channels[1].Id, "second should be Alpha Two (DisplayName match, alphabetically second)") + + // Non-DisplayName matches come second, also sorted alphabetically by DisplayName + assert.Equal(t, chPurposeOnly1.Id, channels[2].Id, "third should be Bravo Channel (no DisplayName match, alphabetically first)") + assert.Equal(t, chPurposeOnly2.Id, channels[3].Id, "fourth should be Zulu Channel (no DisplayName match, alphabetically second)") + }) + + t.Run("AutoCompleteInTeam/no DisplayName matches still returns results sorted by DisplayName", func(t *testing.T) { + s.GetMaster().Exec("TRUNCATE Channels") + + sortTeam := &model.Team{ + DisplayName: "sort-team", + Name: NewTestID(), + Email: MakeEmail(), + Type: model.TeamOpen, + } + sortTeam, err := ss.Team().Save(sortTeam) + require.NoError(t, err) + + sortUser := model.NewId() + _, err = ss.Team().SaveMember(rctx, &model.TeamMember{TeamId: sortTeam.Id, UserId: sortUser}, -1) + require.NoError(t, err) + + // All channels match on Purpose only, not DisplayName + ch1 := model.Channel{TeamId: sortTeam.Id, DisplayName: "Zulu Display", Name: NewTestID(), Purpose: "searchterm stuff", Type: model.ChannelTypeOpen} + _, err = ss.Channel().Save(rctx, &ch1, -1) + require.NoError(t, err) + + ch2 := model.Channel{TeamId: sortTeam.Id, DisplayName: "Alpha Display", Name: NewTestID(), Purpose: "searchterm things", Type: model.ChannelTypeOpen} + _, err = ss.Channel().Save(rctx, &ch2, -1) + require.NoError(t, err) + + ch3 := model.Channel{TeamId: sortTeam.Id, DisplayName: "Mike Display", Name: NewTestID(), Purpose: "searchterm items", Type: model.ChannelTypeOpen} + _, err = ss.Channel().Save(rctx, &ch3, -1) + require.NoError(t, err) + + channels, err := ss.Channel().AutocompleteInTeam(rctx, sortTeam.Id, sortUser, "searchterm", false, false) + require.NoError(t, err) + require.Len(t, channels, 3) + + // All in the same priority bucket (no DisplayName match), sorted by DisplayName + assert.Equal(t, ch2.Id, channels[0].Id, "Alpha Display should be first") + assert.Equal(t, ch3.Id, channels[1].Id, "Mike Display should be second") + assert.Equal(t, ch1.Id, channels[2].Id, "Zulu Display should be third") + }) + + t.Run("AutoCompleteInTeam/DisplayName match is not cut off by limit", func(t *testing.T) { + s.GetMaster().Exec("TRUNCATE Channels") + + sortTeam := &model.Team{ + DisplayName: "sort-team", + Name: NewTestID(), + Email: MakeEmail(), + Type: model.TeamOpen, + } + sortTeam, err := ss.Team().Save(sortTeam) + require.NoError(t, err) + + sortUser := model.NewId() + _, err = ss.Team().SaveMember(rctx, &model.TeamMember{TeamId: sortTeam.Id, UserId: sortUser}, -1) + require.NoError(t, err) + + // Create 50 channels that match only on Purpose (not DisplayName). + for i := range model.ChannelSearchDefaultLimit { + _, err = ss.Channel().Save(rctx, &model.Channel{ + TeamId: sortTeam.Id, + DisplayName: fmt.Sprintf("AAA Channel %03d", i), + Name: NewTestID(), + Purpose: "findme in purpose", + Type: model.ChannelTypeOpen, + }, -1) + require.NoError(t, err) + } + + // Create 1 channel whose DisplayName matches the term but sorts last alphabetically. + chDisplayMatch := model.Channel{ + TeamId: sortTeam.Id, + DisplayName: "ZZZ findme channel", + Name: NewTestID(), + Type: model.ChannelTypeOpen, + } + _, err = ss.Channel().Save(rctx, &chDisplayMatch, -1) + require.NoError(t, err) + + channels, err := ss.Channel().AutocompleteInTeam(rctx, sortTeam.Id, sortUser, "findme", false, false) + require.NoError(t, err) + require.Len(t, channels, model.ChannelSearchDefaultLimit) + + // The DisplayName-matching channel must be first despite sorting last alphabetically. + assert.Equal(t, chDisplayMatch.Id, channels[0].Id, "DisplayName match should be returned first, not cut off by limit") + }) } func testAutocomplete(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { @@ -6419,6 +6551,101 @@ func testAutocomplete(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore } }) + t.Run("channels with a matching DisplayName are sorted before those matching other fields", func(t *testing.T) { + // Clean slate for ordering tests + s.GetMaster().Exec("TRUNCATE Channels") + + sortTeam := &model.Team{ + DisplayName: "sort-team", + Name: NewTestID(), + Email: MakeEmail(), + Type: model.TeamOpen, + } + sortTeam, err2 := ss.Team().Save(sortTeam) + require.NoError(t, err2) + + sortUser := model.NewId() + _, err2 = ss.Team().SaveMember(rctx, &model.TeamMember{TeamId: sortTeam.Id, UserId: sortUser}, -1) + require.NoError(t, err2) + + // Channels where DisplayName matches "alpha": + chDisplayAlpha2 := model.Channel{TeamId: sortTeam.Id, DisplayName: "Alpha Two", Name: NewTestID(), Type: model.ChannelTypeOpen} + _, err2 = ss.Channel().Save(rctx, &chDisplayAlpha2, -1) + require.NoError(t, err2) + + chDisplayAlpha1 := model.Channel{TeamId: sortTeam.Id, DisplayName: "Alpha One", Name: NewTestID(), Type: model.ChannelTypeOpen} + _, err2 = ss.Channel().Save(rctx, &chDisplayAlpha1, -1) + require.NoError(t, err2) + + // Channels where only Purpose matches "alpha" (DisplayName does NOT contain "alpha"): + chPurposeOnly2 := model.Channel{TeamId: sortTeam.Id, DisplayName: "Zulu Channel", Name: NewTestID(), Purpose: "alpha related work", Type: model.ChannelTypeOpen} + _, err2 = ss.Channel().Save(rctx, &chPurposeOnly2, -1) + require.NoError(t, err2) + + chPurposeOnly1 := model.Channel{TeamId: sortTeam.Id, DisplayName: "Bravo Channel", Name: NewTestID(), Purpose: "alpha discussions", Type: model.ChannelTypeOpen} + _, err2 = ss.Channel().Save(rctx, &chPurposeOnly1, -1) + require.NoError(t, err2) + + channels, err2 := ss.Channel().Autocomplete(rctx, sortUser, "alpha", false, false) + require.NoError(t, err2) + require.Len(t, channels, 4) + + // DisplayName matches come first, sorted alphabetically by DisplayName + assert.Equal(t, chDisplayAlpha1.Id, channels[0].Id, "first should be Alpha One (DisplayName match, alphabetically first)") + assert.Equal(t, chDisplayAlpha2.Id, channels[1].Id, "second should be Alpha Two (DisplayName match, alphabetically second)") + + // Non-DisplayName matches come second, also sorted alphabetically by DisplayName + assert.Equal(t, chPurposeOnly1.Id, channels[2].Id, "third should be Bravo Channel (no DisplayName match, alphabetically first)") + assert.Equal(t, chPurposeOnly2.Id, channels[3].Id, "fourth should be Zulu Channel (no DisplayName match, alphabetically second)") + }) + + t.Run("channels with a matching DisplayName are sorted before those matching other fields, even when the results are cut off", func(t *testing.T) { + s.GetMaster().Exec("TRUNCATE Channels") + + sortTeam := &model.Team{ + DisplayName: "sort-team", + Name: NewTestID(), + Email: MakeEmail(), + Type: model.TeamOpen, + } + sortTeam, err2 := ss.Team().Save(sortTeam) + require.NoError(t, err2) + + sortUser := model.NewId() + _, err2 = ss.Team().SaveMember(rctx, &model.TeamMember{TeamId: sortTeam.Id, UserId: sortUser}, -1) + require.NoError(t, err2) + + // Create 50 channels that match only on Purpose (not DisplayName). + // Give them DisplayNames that sort before the DisplayName-matching channel. + for i := range model.ChannelSearchDefaultLimit { + _, err2 = ss.Channel().Save(rctx, &model.Channel{ + TeamId: sortTeam.Id, + DisplayName: fmt.Sprintf("AAA Channel %03d", i), + Name: NewTestID(), + Purpose: "findme in purpose", + Type: model.ChannelTypeOpen, + }, -1) + require.NoError(t, err2) + } + + // Create 1 channel whose DisplayName matches the term but sorts last alphabetically. + chDisplayMatch := model.Channel{ + TeamId: sortTeam.Id, + DisplayName: "ZZZ findme channel", + Name: NewTestID(), + Type: model.ChannelTypeOpen, + } + _, err2 = ss.Channel().Save(rctx, &chDisplayMatch, -1) + require.NoError(t, err2) + + channels, err2 := ss.Channel().Autocomplete(rctx, sortUser, "findme", false, false) + require.NoError(t, err2) + require.Len(t, channels, model.ChannelSearchDefaultLimit) + + // The DisplayName-matching channel must be first despite sorting last alphabetically. + assert.Equal(t, chDisplayMatch.Id, channels[0].Id, "DisplayName match should be returned first, not cut off by limit") + }) + t.Run("Limit", func(t *testing.T) { for i := range model.ChannelSearchDefaultLimit + 10 { _, err = ss.Channel().Save(rctx, &model.Channel{