MM-67953 Changed sorting of channels in some places to prioritize display name matches (#35679)
Some checks failed
API / build (push) Has been cancelled
Server CI / Compute Go Version (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Server CI / Check mocks (push) Has been cancelled
Server CI / Check go mod tidy (push) Has been cancelled
Server CI / check-style (push) Has been cancelled
Server CI / Check serialization methods for hot structs (push) Has been cancelled
Server CI / Vet API (push) Has been cancelled
Server CI / Check migration files (push) Has been cancelled
Server CI / Generate email templates (push) Has been cancelled
Server CI / Check store layers (push) Has been cancelled
Server CI / Check mmctl docs (push) Has been cancelled
Server CI / Postgres with binary parameters (push) Has been cancelled
Server CI / Postgres (push) Has been cancelled
Server CI / Postgres (FIPS) (push) Has been cancelled
Server CI / Generate Test Coverage (push) Has been cancelled
Server CI / Run mmctl tests (push) Has been cancelled
Server CI / Run mmctl tests (FIPS) (push) Has been cancelled
Server CI / Build mattermost server app (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled

* MM-67953 Changed Browse Channels modal to prioritize channels with DisplayName matching search query

* MM-67953 Changed channel shortlink autocomplete to prioritize non-member channels with DisplayName matching search query

* Shared orderByDisplayNameMatch between both methods

* Add E2E tests

* Update existing E2E test to use new fixture

* Run Prettier on E2E tests

* And fix linting issue

* And fix the fix...

* Run server code linter
This commit is contained in:
Harrison Healey 2026-03-20 17:30:29 -04:00 committed by GitHub
parent 4c25d03f67
commit 5f8c77a3ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 473 additions and 36 deletions

View file

@ -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}`);
}
}

View file

@ -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,

View file

@ -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<NewChannelModal> {
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<BrowseChannelsModal> {
await this.sidebarLeft.browseOrCreateChannelButton.click();
await this.page.getByText('Browse channels').click();
await this.browseChannelsModal.toBeVisible();
return this.browseChannelsModal;
}
async openCreateTeamForm(): Promise<CreateTeamForm> {
await this.sidebarLeft.teamMenuButton.click();
await this.teamMenu.toBeVisible();

View file

@ -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]

View file

@ -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');
},
);

View file

@ -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);
},
);

View file

@ -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)))

View file

@ -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{