[MM-62193] Fix search in group channels (#29916)
* feat: Add profile loading for group channels in search autocomplete * refactor: Use getChannelNameForSearchShortcut for channel name formatting * refactor: Improve channel search term generation logic * refactor: Simplify search channel provider logic and improve code readability * fix: Improve channel search shortcut name handling with fallback * refactor: Avoid overriding channels parameter in search channel provider * refactor: Revert variable names to original style in search channel provider * feat: Add getChannelNameForSearchShortcut selector for channel name retrieval * refactor: Extract common channel name search logic into helper function * fix text overflowing from search bar * test: Add tests for completeDirectGroupInfo function * test: Add type assertion for UsersState in channel_utils test * test: add test case for completeDirectGroupInfo with omitCurrentUser false * improve name composition for channels in search * eslint * lint --------- Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
@@ -94,6 +94,14 @@ const NewSearchContainer = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const NewSearchTerms = styled.span`
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
margin-right: 32px;
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
const NewSearch = (): JSX.Element => {
|
const NewSearch = (): JSX.Element => {
|
||||||
const currentChannelName = useSelector(getCurrentChannelNameForSearchShortcut);
|
const currentChannelName = useSelector(getCurrentChannelNameForSearchShortcut);
|
||||||
const searchTerms = useSelector(getSearchTerms) || '';
|
const searchTerms = useSelector(getSearchTerms) || '';
|
||||||
@@ -260,7 +268,7 @@ const NewSearch = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
</SearchTypeBadge>
|
</SearchTypeBadge>
|
||||||
)}
|
)}
|
||||||
{searchTerms && <span tabIndex={0}>{searchTerms}</span>}
|
{searchTerms && <NewSearchTerms tabIndex={0}>{searchTerms}</NewSearchTerms>}
|
||||||
{searchTerms && (
|
{searchTerms && (
|
||||||
<CloseIcon
|
<CloseIcon
|
||||||
data-testid='input-clear'
|
data-testid='input-clear'
|
||||||
|
|||||||
@@ -143,9 +143,17 @@ const SearchBox = forwardRef(
|
|||||||
const escapedMatchedPretext = escapeRegex(matchedPretext);
|
const escapedMatchedPretext = escapeRegex(matchedPretext);
|
||||||
const caretPosition = getCaretPosition();
|
const caretPosition = getCaretPosition();
|
||||||
const extraSpace = caretPosition === searchTerms.length ? ' ' : '';
|
const extraSpace = caretPosition === searchTerms.length ? ' ' : '';
|
||||||
|
const existing = searchTerms.slice(0, caretPosition).replace(new RegExp(escapedMatchedPretext + '$', 'i'), '');
|
||||||
|
|
||||||
|
// if existing ends with @ and value starts with one, remove it.
|
||||||
|
let val = value;
|
||||||
|
if (existing.endsWith('@') && value.startsWith('@')) {
|
||||||
|
val = value.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
setSearchTerms(
|
setSearchTerms(
|
||||||
searchTerms.slice(0, caretPosition).replace(new RegExp(escapedMatchedPretext + '$', 'i'), '') +
|
searchTerms.slice(0, caretPosition).replace(new RegExp(escapedMatchedPretext + '$', 'i'), '') +
|
||||||
value +
|
val +
|
||||||
extraSpace +
|
extraSpace +
|
||||||
searchTerms.slice(caretPosition),
|
searchTerms.slice(caretPosition),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
|
|
||||||
import type {ServerError} from '@mattermost/types/errors';
|
import type {ServerError} from '@mattermost/types/errors';
|
||||||
|
|
||||||
|
import {getChannelNameForSearchShortcut} from 'mattermost-redux/selectors/entities/channels';
|
||||||
import {isDirectChannel, isGroupChannel, sortChannelsByTypeListAndDisplayName} from 'mattermost-redux/utils/channel_utils';
|
import {isDirectChannel, isGroupChannel, sortChannelsByTypeListAndDisplayName} from 'mattermost-redux/utils/channel_utils';
|
||||||
|
|
||||||
|
import {loadProfilesForGroupChannels} from 'actions/user_actions';
|
||||||
import {getCurrentLocale} from 'selectors/i18n';
|
import {getCurrentLocale} from 'selectors/i18n';
|
||||||
import store from 'stores/redux_store';
|
import store from 'stores/redux_store';
|
||||||
|
|
||||||
@@ -16,20 +18,7 @@ import type {ResultsCallback} from './provider';
|
|||||||
import SearchChannelSuggestion from './search_channel_suggestion';
|
import SearchChannelSuggestion from './search_channel_suggestion';
|
||||||
|
|
||||||
const getState = store.getState;
|
const getState = store.getState;
|
||||||
|
const dispatch = store.dispatch;
|
||||||
function itemToTerm(isAtSearch: boolean, item: { type: string; display_name: string; name: string }) {
|
|
||||||
const prefix = isAtSearch ? '' : '@';
|
|
||||||
if (item.type === Constants.DM_CHANNEL) {
|
|
||||||
return prefix + item.display_name;
|
|
||||||
}
|
|
||||||
if (item.type === Constants.GM_CHANNEL) {
|
|
||||||
return prefix + item.display_name.replace(/ /g, '');
|
|
||||||
}
|
|
||||||
if (item.type === Constants.OPEN_CHANNEL || item.type === Constants.PRIVATE_CHANNEL) {
|
|
||||||
return item.name;
|
|
||||||
}
|
|
||||||
return item.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SearchChannelAutocomplete = (term: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void) => void;
|
type SearchChannelAutocomplete = (term: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void) => void;
|
||||||
|
|
||||||
@@ -43,45 +32,59 @@ export default class SearchChannelProvider extends Provider {
|
|||||||
|
|
||||||
handlePretextChanged(pretext: string, resultsCallback: ResultsCallback<Channel>) {
|
handlePretextChanged(pretext: string, resultsCallback: ResultsCallback<Channel>) {
|
||||||
const captured = (/\b(?:in|channel):\s*(\S*)$/i).exec(pretext.toLowerCase());
|
const captured = (/\b(?:in|channel):\s*(\S*)$/i).exec(pretext.toLowerCase());
|
||||||
if (captured) {
|
if (!captured) {
|
||||||
let channelPrefix = captured[1];
|
return false;
|
||||||
const isAtSearch = channelPrefix.startsWith('@');
|
|
||||||
if (isAtSearch) {
|
|
||||||
channelPrefix = channelPrefix.replace(/^@/, '');
|
|
||||||
}
|
|
||||||
const isTildeSearch = channelPrefix.startsWith('~');
|
|
||||||
if (isTildeSearch) {
|
|
||||||
channelPrefix = channelPrefix.replace(/^~/, '');
|
|
||||||
}
|
|
||||||
this.startNewRequest(channelPrefix);
|
|
||||||
|
|
||||||
this.autocompleteChannelsForSearch(
|
|
||||||
channelPrefix,
|
|
||||||
(data: Channel[]) => {
|
|
||||||
if (this.shouldCancelDispatch(channelPrefix)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let channels = data;
|
|
||||||
if (isAtSearch) {
|
|
||||||
channels = channels.filter((ch: Channel) => isDirectChannel(ch) || isGroupChannel(ch));
|
|
||||||
}
|
|
||||||
|
|
||||||
const locale = getCurrentLocale(getState());
|
|
||||||
|
|
||||||
channels = channels.sort(sortChannelsByTypeListAndDisplayName.bind(null, locale, [Constants.OPEN_CHANNEL, Constants.PRIVATE_CHANNEL, Constants.DM_CHANNEL, Constants.GM_CHANNEL]));
|
|
||||||
const channelNames = channels.map(itemToTerm.bind(null, isAtSearch));
|
|
||||||
|
|
||||||
resultsCallback({
|
|
||||||
matchedPretext: channelPrefix,
|
|
||||||
terms: channelNames,
|
|
||||||
items: channels,
|
|
||||||
component: SearchChannelSuggestion,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Boolean(captured);
|
const prefix = captured[1].replace(/^[@~]/, '');
|
||||||
|
const isAtSearch = captured[1].startsWith('@');
|
||||||
|
|
||||||
|
this.startNewRequest(prefix);
|
||||||
|
|
||||||
|
this.autocompleteChannelsForSearch(
|
||||||
|
prefix,
|
||||||
|
async (data: Channel[]) => {
|
||||||
|
if (this.shouldCancelDispatch(prefix)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let channels = data;
|
||||||
|
if (isAtSearch) {
|
||||||
|
channels = data.filter((ch: Channel) =>
|
||||||
|
isDirectChannel(ch) || isGroupChannel(ch),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load profiles for group channels if needed
|
||||||
|
const groupChannels = channels.filter(isGroupChannel);
|
||||||
|
if (groupChannels.length > 0) {
|
||||||
|
await dispatch(loadProfilesForGroupChannels(groupChannels));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort channels
|
||||||
|
const locale = getCurrentLocale(getState());
|
||||||
|
channels.sort(sortChannelsByTypeListAndDisplayName.bind(null, locale, [
|
||||||
|
Constants.OPEN_CHANNEL,
|
||||||
|
Constants.PRIVATE_CHANNEL,
|
||||||
|
Constants.DM_CHANNEL,
|
||||||
|
Constants.GM_CHANNEL,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Get channel names using the selector
|
||||||
|
const channelNames = channels.map((channel) => {
|
||||||
|
const name = getChannelNameForSearchShortcut(getState(), channel.id) || channel.name;
|
||||||
|
return isAtSearch && !name.startsWith('@') ? `@${name}` : name;
|
||||||
|
});
|
||||||
|
|
||||||
|
resultsCallback({
|
||||||
|
matchedPretext: prefix,
|
||||||
|
terms: channelNames,
|
||||||
|
items: channels,
|
||||||
|
component: SearchChannelSuggestion,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,26 @@ export const getCurrentChannel: (state: GlobalState) => Channel | undefined = cr
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getChannelNameForSearch = (channel: Channel | undefined, users: UsersState): string | undefined => {
|
||||||
|
if (!channel) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only get the extra info from users if we need it
|
||||||
|
if (channel.type === General.DM_CHANNEL) {
|
||||||
|
const dmChannelWithInfo = completeDirectChannelInfo(users, Preferences.DISPLAY_PREFER_USERNAME, channel);
|
||||||
|
return `@${dmChannelWithInfo.display_name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace spaces in GM channel names
|
||||||
|
if (channel.type === General.GM_CHANNEL) {
|
||||||
|
const gmChannelWithInfo = completeDirectGroupInfo(users, Preferences.DISPLAY_PREFER_USERNAME, channel, false);
|
||||||
|
return `@${gmChannelWithInfo.display_name.replace(/\s/g, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel.name;
|
||||||
|
};
|
||||||
|
|
||||||
export const getCurrentChannelNameForSearchShortcut: (state: GlobalState) => string | undefined = createSelector(
|
export const getCurrentChannelNameForSearchShortcut: (state: GlobalState) => string | undefined = createSelector(
|
||||||
'getCurrentChannelNameForSearchShortcut',
|
'getCurrentChannelNameForSearchShortcut',
|
||||||
getAllChannels,
|
getAllChannels,
|
||||||
@@ -245,20 +265,18 @@ export const getCurrentChannelNameForSearchShortcut: (state: GlobalState) => str
|
|||||||
(state: GlobalState): UsersState => state.entities.users,
|
(state: GlobalState): UsersState => state.entities.users,
|
||||||
(allChannels: IDMappedObjects<Channel>, currentChannelId: string, users: UsersState): string | undefined => {
|
(allChannels: IDMappedObjects<Channel>, currentChannelId: string, users: UsersState): string | undefined => {
|
||||||
const channel = allChannels[currentChannelId];
|
const channel = allChannels[currentChannelId];
|
||||||
|
return getChannelNameForSearch(channel, users);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Only get the extra info from users if we need it
|
export const getChannelNameForSearchShortcut: (state: GlobalState, channelId: string) => string | undefined = createSelector(
|
||||||
if (channel?.type === General.DM_CHANNEL) {
|
'getChannelNameForSearchShortcut',
|
||||||
const dmChannelWithInfo = completeDirectChannelInfo(users, Preferences.DISPLAY_PREFER_USERNAME, channel);
|
getAllChannels,
|
||||||
return `@${dmChannelWithInfo.display_name}`;
|
(state: GlobalState): UsersState => state.entities.users,
|
||||||
}
|
(state: GlobalState, channelId: string): string => channelId,
|
||||||
|
(allChannels: IDMappedObjects<Channel>, users: UsersState, channelId: string): string | undefined => {
|
||||||
// Replace spaces in GM channel names
|
const channel = allChannels[channelId];
|
||||||
if (channel?.type === General.GM_CHANNEL) {
|
return getChannelNameForSearch(channel, users);
|
||||||
const gmChannelWithInfo = completeDirectGroupInfo(users, Preferences.DISPLAY_PREFER_USERNAME, channel, false);
|
|
||||||
return `@${gmChannelWithInfo.display_name.replace(/\s/g, '')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return channel?.name;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import type {ChannelNotifyProps} from '@mattermost/types/channels';
|
import type {ChannelNotifyProps} from '@mattermost/types/channels';
|
||||||
import type {Post} from '@mattermost/types/posts';
|
import type {Post} from '@mattermost/types/posts';
|
||||||
|
import type {UsersState} from '@mattermost/types/users';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
areChannelMentionsIgnored,
|
areChannelMentionsIgnored,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
sortChannelsByRecency,
|
sortChannelsByRecency,
|
||||||
sortChannelsByDisplayName,
|
sortChannelsByDisplayName,
|
||||||
sortChannelsByTypeListAndDisplayName,
|
sortChannelsByTypeListAndDisplayName,
|
||||||
|
completeDirectGroupInfo,
|
||||||
} from 'mattermost-redux/utils/channel_utils';
|
} from 'mattermost-redux/utils/channel_utils';
|
||||||
|
|
||||||
import TestHelper from '../../test/test_helper';
|
import TestHelper from '../../test/test_helper';
|
||||||
@@ -179,4 +181,62 @@ describe('ChannelUtils', () => {
|
|||||||
const expectedOutput = JSON.stringify([channelDM, channelGM, channelPrivate, channelOpen1, channelOpen2]);
|
const expectedOutput = JSON.stringify([channelDM, channelGM, channelPrivate, channelOpen1, channelOpen2]);
|
||||||
expect(actualOutput).toEqual(expectedOutput);
|
expect(actualOutput).toEqual(expectedOutput);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('completeDirectGroupInfo', () => {
|
||||||
|
const currentUserId = 'current_user_id';
|
||||||
|
const currentUser = {id: currentUserId, username: 'current_user_id', first_name: '', last_name: ''};
|
||||||
|
const user1 = {id: 'user1', username: 'user1', first_name: '', last_name: ''};
|
||||||
|
const user2 = {id: 'user2', username: 'user2', first_name: '', last_name: ''};
|
||||||
|
const user3 = {id: 'user3', username: 'user3', first_name: '', last_name: ''};
|
||||||
|
|
||||||
|
const usersState = {
|
||||||
|
currentUserId,
|
||||||
|
profiles: {
|
||||||
|
[user1.id]: user1,
|
||||||
|
[user2.id]: user2,
|
||||||
|
[user3.id]: user3,
|
||||||
|
[currentUserId]: currentUser,
|
||||||
|
},
|
||||||
|
profilesInChannel: {
|
||||||
|
channel1: new Set([user1.id, user2.id]),
|
||||||
|
channel2: new Set([user1.id, user2.id, user3.id]),
|
||||||
|
},
|
||||||
|
} as any as UsersState;
|
||||||
|
|
||||||
|
const baseChannel = TestHelper.fakeChannelOverride({
|
||||||
|
id: 'channel1',
|
||||||
|
type: General.GM_CHANNEL,
|
||||||
|
display_name: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set display name from profilesInChannel', () => {
|
||||||
|
const channel = {...baseChannel, id: 'channel1'};
|
||||||
|
const result = completeDirectGroupInfo(usersState, 'username', channel);
|
||||||
|
expect(result.display_name).toBe('user1, user2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set display name for larger group', () => {
|
||||||
|
const channel = {...baseChannel, id: 'channel2'};
|
||||||
|
const result = completeDirectGroupInfo(usersState, 'username', channel);
|
||||||
|
expect(result.display_name).toBe('user1, user2, user3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use existing display_name when no profilesInChannel', () => {
|
||||||
|
const channel = {...baseChannel, id: 'channel3', display_name: 'user1, user2'};
|
||||||
|
const result = completeDirectGroupInfo(usersState, 'username', channel);
|
||||||
|
expect(result.display_name).toBe('user1, user2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original channel when usernames not found', () => {
|
||||||
|
const channel = {...baseChannel, id: 'channel3', display_name: 'unknown1, unknown2'};
|
||||||
|
const result = completeDirectGroupInfo(usersState, 'username', channel);
|
||||||
|
expect(result).toBe(channel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include current user when omitCurrentUser is false', () => {
|
||||||
|
const channel = {...baseChannel, id: 'channel1'};
|
||||||
|
const result = completeDirectGroupInfo(usersState, 'username', channel, false);
|
||||||
|
expect(result.display_name).toBe('current_user_id, user1, user2');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ export function completeDirectGroupInfo(usersState: UsersState, teammateNameDisp
|
|||||||
const gm = {...channel};
|
const gm = {...channel};
|
||||||
|
|
||||||
if (profilesIds) {
|
if (profilesIds) {
|
||||||
|
// sometimes the current user is not part of the profilesInChannel
|
||||||
|
if (!omitCurrentUser) {
|
||||||
|
profilesIds.add(currentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
gm.display_name = getGroupDisplayNameFromUserIds(profilesIds, profiles, currentUserId, teammateNameDisplay, omitCurrentUser);
|
gm.display_name = getGroupDisplayNameFromUserIds(profilesIds, profiles, currentUserId, teammateNameDisplay, omitCurrentUser);
|
||||||
return gm;
|
return gm;
|
||||||
}
|
}
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user