MM-54173: Part 2 - unify at mention components (#24487)

This commit is contained in:
Ross Baquir 2023-10-19 23:34:29 -07:00 committed by GitHub
parent c2bc4008fc
commit 6e214edb87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 500 additions and 438 deletions

View File

@ -3,25 +3,55 @@
exports[`components/AtMention should match snapshot when mentioning a group followed by punctuation 1`] = `
<Fragment>
<span>
<Memo(AtMentionGroup)
group={
Object {
"allow_reference": true,
"create_at": 1,
"delete_at": 0,
"description": "",
"display_name": "group_display_name",
"has_syncables": false,
"id": "qwerty1",
"member_count": 0,
"name": "developers",
"remote_id": "",
"scheme_admin": false,
"source": "",
"update_at": 1,
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<Connect(Component)
group={
Object {
"allow_reference": true,
"create_at": 1,
"delete_at": 0,
"description": "",
"display_name": "group_display_name",
"has_syncables": false,
"id": "qwerty1",
"member_count": 0,
"name": "developers",
"remote_id": "",
"scheme_admin": false,
"source": "",
"update_at": 1,
}
}
}
/>
hide={[Function]}
returnFocus={[Function]}
showUserOverlay={[Function]}
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<a
aria-haspopup="dialog"
className="group-mention-link"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
@developers
</a>
</span>
.
</Fragment>
@ -30,25 +60,55 @@ exports[`components/AtMention should match snapshot when mentioning a group foll
exports[`components/AtMention should match snapshot when mentioning a group that is allowed reference 1`] = `
<Fragment>
<span>
<Memo(AtMentionGroup)
group={
Object {
"allow_reference": true,
"create_at": 1,
"delete_at": 0,
"description": "",
"display_name": "group_display_name",
"has_syncables": false,
"id": "qwerty1",
"member_count": 0,
"name": "developers",
"remote_id": "",
"scheme_admin": false,
"source": "",
"update_at": 1,
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<Connect(Component)
group={
Object {
"allow_reference": true,
"create_at": 1,
"delete_at": 0,
"description": "",
"display_name": "group_display_name",
"has_syncables": false,
"id": "qwerty1",
"member_count": 0,
"name": "developers",
"remote_id": "",
"scheme_admin": false,
"source": "",
"update_at": 1,
}
}
}
/>
hide={[Function]}
returnFocus={[Function]}
showUserOverlay={[Function]}
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<a
aria-haspopup="dialog"
className="group-mention-link"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
@developers
</a>
</span>
</Fragment>
`;
@ -91,12 +151,20 @@ exports[`components/AtMention should match snapshot when mentioning current user
>
<Connect(injectIntl(ProfilePopover))
className="user-profile-popover"
hasMention={false}
hide={[Function]}
src="/api/v4/users/abc1/image"
userId="abc1"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<a
aria-haspopup="dialog"
className="mention-link"
@ -123,12 +191,20 @@ exports[`components/AtMention should match snapshot when mentioning user 1`] = `
>
<Connect(injectIntl(ProfilePopover))
className="user-profile-popover"
hasMention={false}
hide={[Function]}
src="/api/v4/users/abc2/image"
userId="abc2"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<a
aria-haspopup="dialog"
className="mention-link"
@ -155,12 +231,20 @@ exports[`components/AtMention should match snapshot when mentioning user contain
>
<Connect(injectIntl(ProfilePopover))
className="user-profile-popover"
hasMention={false}
hide={[Function]}
src="/api/v4/users/abc3/image"
userId="abc3"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<a
aria-haspopup="dialog"
className="mention-link"
@ -188,12 +272,20 @@ exports[`components/AtMention should match snapshot when mentioning user contain
>
<Connect(injectIntl(ProfilePopover))
className="user-profile-popover"
hasMention={false}
hide={[Function]}
src="/api/v4/users/abc3/image"
userId="abc3"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<a
aria-haspopup="dialog"
className="mention-link"
@ -220,12 +312,20 @@ exports[`components/AtMention should match snapshot when mentioning user followe
>
<Connect(injectIntl(ProfilePopover))
className="user-profile-popover"
hasMention={false}
hide={[Function]}
src="/api/v4/users/abc2/image"
userId="abc2"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<a
aria-haspopup="dialog"
className="mention-link"
@ -253,12 +353,20 @@ exports[`components/AtMention should match snapshot when mentioning user with di
>
<Connect(injectIntl(ProfilePopover))
className="user-profile-popover"
hasMention={false}
hide={[Function]}
src="/api/v4/users/abc2/image"
userId="abc2"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<a
aria-haspopup="dialog"
className="mention-link"
@ -285,12 +393,20 @@ exports[`components/AtMention should match snapshot when mentioning user with mi
>
<Connect(injectIntl(ProfilePopover))
className="user-profile-popover"
hasMention={false}
hide={[Function]}
src="/api/v4/users/abc2/image"
userId="abc2"
/>
</Overlay>
<Overlay
animation={[Function]}
onHide={[Function]}
placement="right"
rootClose={true}
show={false}
>
<span />
</Overlay>
<a
aria-haspopup="dialog"
className="mention-link"

View File

@ -3,7 +3,6 @@
import {shallow} from 'enzyme';
import React from 'react';
import type {RefObject} from 'react';
import {General} from 'mattermost-redux/constants';
@ -32,7 +31,7 @@ describe('components/AtMention', () => {
};
test('should match snapshot when mentioning user', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='user1'
@ -45,7 +44,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when mentioning user with different teammate name display setting', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='user1'
@ -59,7 +58,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when mentioning user followed by punctuation', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='user1...'
@ -72,7 +71,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when mentioning user containing punctuation', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='userdot.'
@ -85,7 +84,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when mentioning user containing and followed by punctuation', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='userdot..'
@ -98,7 +97,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when mentioning user with mixed case', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='USeR1'
@ -111,7 +110,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when mentioning current user', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='currentUser'
@ -124,7 +123,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when mentioning all', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='all'
@ -137,7 +136,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when mentioning all with mixed case', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='aLL'
@ -150,7 +149,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when not mentioning a user', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='notauser'
@ -163,7 +162,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when not mentioning a user with mixed case', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='NOTAuser'
@ -176,7 +175,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when mentioning a group that is allowed reference', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='developers'
@ -189,7 +188,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when mentioning a group that is allowed reference with group highlight disabled', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='developers'
@ -203,7 +202,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when mentioning a group that is not allowed reference', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='marketing'
@ -216,7 +215,7 @@ describe('components/AtMention', () => {
});
test('should match snapshot when mentioning a group followed by punctuation', () => {
const wrapper = shallow<AtMention>(
const wrapper = shallow(
<AtMention
{...baseProps}
mentionName='developers.'
@ -227,52 +226,4 @@ describe('components/AtMention', () => {
expect(wrapper).toMatchSnapshot();
});
test('should have placement state based on ref position of click handler', () => {
const wrapper = shallow<AtMention>(
<AtMention
{...baseProps}
mentionName={'user1'}
>
{'(at)-user1'}
</AtMention>,
);
const instance = wrapper.instance();
instance.buttonRef = {
current: {
getBoundingClientRect: () => ({
top: 550,
}),
},
}as RefObject<HTMLAnchorElement>;
wrapper.instance().handleClick({preventDefault: jest.fn(), target: AtMention} as any);
expect(wrapper.state('placement')).toEqual('top');
instance.buttonRef = {
current: {
getBoundingClientRect: () => ({
top: 500,
bottom: 100,
}),
},
}as RefObject<HTMLAnchorElement>;
wrapper.instance().handleClick({preventDefault: jest.fn(), target: AtMention} as any);
expect(wrapper.state('placement')).toEqual('bottom');
instance.buttonRef = {
current: {
getBoundingClientRect: () => ({
top: 200,
bottom: 1000,
}),
},
} as RefObject<HTMLAnchorElement>;
wrapper.instance().handleClick({preventDefault: jest.fn(), target: AtMention} as any);
expect(wrapper.state('placement')).toEqual('left');
});
});

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useRef, useState, useMemo} from 'react';
import {Overlay} from 'react-bootstrap';
import type {Group} from '@mattermost/types/groups';
@ -10,15 +10,19 @@ import type {UserProfile} from '@mattermost/types/users';
import {Client4} from 'mattermost-redux/client';
import {displayUsername} from 'mattermost-redux/utils/user_utils';
import AtMentionGroup from 'components/at_mention/at_mention_group';
import ProfilePopover from 'components/profile_popover';
import UserGroupPopover from 'components/user_group_popover';
import {MAX_LIST_HEIGHT, getListHeight, VIEWPORT_SCALE_FACTOR} from 'components/user_group_popover/group_member_list/group_member_list';
import Constants from 'utils/constants';
import type {A11yFocusEventDetail} from 'utils/constants';
import Constants, {A11yCustomEventTypes} from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {popOverOverlayPosition} from 'utils/position_utils';
import {popOverOverlayPosition, approxGroupPopOverHeight} from 'utils/position_utils';
import {getUserOrGroupFromMentionName} from 'utils/post_utils';
import {getViewportSize} from 'utils/utils';
const HEADER_HEIGHT_ESTIMATE = 130;
type Props = {
currentUserId: string;
mentionName: string;
@ -32,123 +36,179 @@ type Props = {
disableGroupHighlight?: boolean;
}
type State = {
show: boolean;
target?: HTMLAnchorElement;
placement?: string;
}
export const AtMention = (props: Props) => {
const ref = useRef<HTMLAnchorElement>(null);
export default class AtMention extends React.PureComponent<Props, State> {
buttonRef: React.RefObject<HTMLAnchorElement>;
const [show, setShow] = useState(false);
const [groupUser, setGroupUser] = useState<UserProfile | undefined>();
const [target, setTarget] = useState<HTMLAnchorElement | undefined>();
const [placement, setPlacement] = useState('right');
static defaultProps: Partial<Props> = {
hasMention: false,
disableHighlight: false,
disableGroupHighlight: false,
};
const [user, group] = useMemo(
() => getUserOrGroupFromMentionName(props.mentionName, props.usersByUsername, props.groupsByName, props.disableGroupHighlight),
[props.mentionName, props.usersByUsername, props.groupsByName, props.disableGroupHighlight],
);
constructor(props: Props) {
super(props);
this.state = {
show: false,
};
this.buttonRef = React.createRef();
}
showOverlay = (target?: HTMLAnchorElement) => {
const targetBounds = this.buttonRef.current?.getBoundingClientRect();
const showOverlay = (target?: HTMLAnchorElement) => {
const targetBounds = ref.current?.getBoundingClientRect();
if (targetBounds) {
const placement = popOverOverlayPosition(targetBounds, getViewportSize().h, getViewportSize().h - 240);
this.setState({target, show: !this.state.show, placement});
let popOverHeight: number;
if (group) {
popOverHeight = approxGroupPopOverHeight(
getListHeight(group.member_count),
getViewportSize().h,
VIEWPORT_SCALE_FACTOR,
HEADER_HEIGHT_ESTIMATE,
MAX_LIST_HEIGHT,
);
} else {
popOverHeight = getViewportSize().h - 240;
}
const placement = popOverOverlayPosition(targetBounds, getViewportSize().h, popOverHeight);
setTarget(target);
setShow(!show);
setGroupUser(undefined);
setPlacement(placement);
}
};
handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
this.showOverlay(e.target as HTMLAnchorElement);
showOverlay(e.target as HTMLAnchorElement);
};
handleKeyDown = (e: React.KeyboardEvent<HTMLAnchorElement>) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLAnchorElement>) => {
if (isKeyPressed(e, Constants.KeyCodes.ENTER) || isKeyPressed(e, Constants.KeyCodes.SPACE)) {
e.preventDefault();
// Prevent propagation so that the message textbox isn't focused
e.stopPropagation();
this.showOverlay(e.target as HTMLAnchorElement);
showOverlay(e.target as HTMLAnchorElement);
}
};
hideOverlay = () => {
this.setState({show: false});
const hideOverlay = () => {
setShow(false);
};
render() {
const user = getUserOrGroupFromMentionName(this.props.usersByUsername, this.props.mentionName) as UserProfile | '';
const showGroupUserOverlay = (user: UserProfile) => {
hideOverlay();
setGroupUser(user);
};
if (!this.props.disableGroupHighlight && !user) {
const group = getUserOrGroupFromMentionName(this.props.groupsByName, this.props.mentionName) as Group | '';
const hideGroupUserOverlay = () => {
setGroupUser(undefined);
};
if (group && group.allow_reference) {
const suffix = this.props.mentionName.substring(group.name.length);
const returnFocus = () => {
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
A11yCustomEventTypes.FOCUS, {
detail: {
target: ref.current,
keyboardOnly: true,
},
},
));
};
return (
<>
<span>
<AtMentionGroup group={group}/>
</span>
{suffix}
</>
);
}
const getPopOver = (user?: UserProfile, group?: Group) => {
if (user) {
return (
<ProfilePopover
className='user-profile-popover'
userId={user.id}
src={Client4.getProfilePictureUrl(user.id, user.last_picture_update)}
hasMention={props.hasMention}
hide={hideOverlay}
channelId={props.channelId}
/>
);
}
if (!user) {
return <React.Fragment>{this.props.children}</React.Fragment>;
if (group) {
return (
<UserGroupPopover
group={group}
hide={hideOverlay}
showUserOverlay={showGroupUserOverlay}
returnFocus={returnFocus}
/>
);
}
const suffix = this.props.mentionName.substring(user.username.length);
const displayName = displayUsername(user, this.props.teammateNameDisplay);
return null;
};
const highlightMention = !this.props.disableHighlight && user.id === this.props.currentUserId;
if (!user && !group) {
return <>{props.children}</>;
}
return (
<>
<span
className={highlightMention ? 'mention--highlight' : undefined}
let suffix = '';
let displayName = '';
let highlightMention = false; // only for user
if (user) {
suffix = props.mentionName.substring(user.username.length);
displayName = displayUsername(user, props.teammateNameDisplay);
highlightMention = !props.disableHighlight && user.id === props.currentUserId;
} else if (group) { // if statement needed for union
suffix = props.mentionName.substring(group.name.length);
displayName = group.name;
}
return (
<>
<span
className={highlightMention ? 'mention--highlight' : undefined}
>
<Overlay
placement={placement}
show={show}
target={target}
rootClose={true}
onHide={hideOverlay}
>
<Overlay
placement={this.state.placement}
show={this.state.show}
target={this.state.target}
rootClose={true}
onHide={this.hideOverlay}
>
{getPopOver(user, group)}
</Overlay>
<Overlay
placement={placement}
show={groupUser !== undefined}
target={target}
onHide={hideGroupUserOverlay}
rootClose={true}
>
{groupUser ? (
<ProfilePopover
className='user-profile-popover'
userId={user.id}
src={Client4.getProfilePictureUrl(user.id, user.last_picture_update)}
hasMention={this.props.hasMention}
hide={this.hideOverlay}
channelId={this.props.channelId}
userId={groupUser.id}
src={Client4.getProfilePictureUrl(groupUser.id, groupUser.last_picture_update)}
channelId={props.channelId}
hasMention={props.hasMention}
hide={hideGroupUserOverlay}
returnFocus={returnFocus}
/>
</Overlay>
<a
className='mention-link'
onClick={this.handleClick}
ref={this.buttonRef}
aria-haspopup='dialog'
role='button'
tabIndex={0}
onKeyDown={this.handleKeyDown}
>
{'@' + displayName}
</a>
</span>
{suffix}
</>
);
}
}
) : <span/> // prevents blank-screen crash when closing groupUser ProfilePopover
}
</Overlay>
<a
onClick={handleClick}
onKeyDown={handleKeyDown}
className={group ? 'group-mention-link' : 'mention-link'}
ref={ref}
aria-haspopup='dialog'
role='button'
tabIndex={0}
>
{'@' + displayName}
</a>
</span>
{suffix}
</>
);
};
export default React.memo(AtMention);

View File

@ -1,159 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef, useState} from 'react';
import {Overlay} from 'react-bootstrap';
import type {Group} from '@mattermost/types/groups';
import type {UserProfile} from '@mattermost/types/users';
import {Client4} from 'mattermost-redux/client';
import ProfilePopover from 'components/profile_popover';
import UserGroupPopover from 'components/user_group_popover';
import {MAX_LIST_HEIGHT, getListHeight, VIEWPORT_SCALE_FACTOR} from 'components/user_group_popover/group_member_list/group_member_list';
import Constants, {A11yCustomEventTypes} from 'utils/constants';
import type {A11yFocusEventDetail} from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {popOverOverlayPosition} from 'utils/position_utils';
import {getViewportSize} from 'utils/utils';
const HEADER_HEIGHT_ESTIMATE = 130;
type Props = {
/**
* The group corresponding to this mention
*/
group: Group;
channelId?: string;
hasMention?: boolean;
}
const AtMentionGroup = (props: Props) => {
const {
group,
channelId,
hasMention,
} = props;
const ref = useRef<HTMLAnchorElement>(null);
const [show, setShow] = useState(false);
const [showUser, setShowUser] = useState<UserProfile | undefined>();
const [target, setTarget] = useState<HTMLAnchorElement | undefined>();
// We need a valid placement here to prevent console errors.
// It will not be used when the overlay is showing.
const [placement, setPlacement] = useState('top');
const showOverlay = (target?: HTMLAnchorElement) => {
const targetBounds = ref.current?.getBoundingClientRect();
if (targetBounds) {
const approximatePopoverHeight = Math.min(
(getViewportSize().h * VIEWPORT_SCALE_FACTOR) + HEADER_HEIGHT_ESTIMATE,
getListHeight(group.member_count) + HEADER_HEIGHT_ESTIMATE,
MAX_LIST_HEIGHT,
);
const placement = popOverOverlayPosition(targetBounds, window.innerHeight, approximatePopoverHeight);
setTarget(target);
setShow(!show);
setShowUser(undefined);
setPlacement(placement);
}
};
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
showOverlay(e.target as HTMLAnchorElement);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLAnchorElement>) => {
if (isKeyPressed(e, Constants.KeyCodes.ENTER) || isKeyPressed(e, Constants.KeyCodes.SPACE)) {
e.preventDefault();
// Prevent propagation so that the message textbox isn't focused
e.stopPropagation();
showOverlay(e.target as HTMLAnchorElement);
}
};
const hideOverlay = () => {
setShow(false);
};
const showUserOverlay = (user: UserProfile) => {
hideOverlay();
setShowUser(user);
};
const hideUserOverlay = () => {
setShowUser(undefined);
};
const returnFocus = () => {
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
A11yCustomEventTypes.FOCUS, {
detail: {
target: ref.current,
keyboardOnly: true,
},
},
));
};
return (
<>
<Overlay
placement={placement}
show={show}
target={target}
rootClose={true}
onHide={hideOverlay}
>
<UserGroupPopover
group={group}
hide={hideOverlay}
showUserOverlay={showUserOverlay}
returnFocus={returnFocus}
/>
</Overlay>
<Overlay
placement={placement}
show={showUser !== undefined}
target={target}
onHide={hideUserOverlay}
rootClose={true}
>
{showUser ? (
<ProfilePopover
className='user-profile-popover'
userId={showUser.id}
src={Client4.getProfilePictureUrl(showUser.id, showUser.last_picture_update)}
channelId={channelId}
hasMention={hasMention}
hide={hideUserOverlay}
returnFocus={returnFocus}
/>
) : <span/>
}
</Overlay>
<a
onClick={handleClick}
onKeyDown={handleKeyDown}
className='group-mention-link'
ref={ref}
aria-haspopup='dialog'
role='button'
tabIndex={0}
>
{'@' + group.name}
</a>
</>
);
};
export default React.memo(AtMentionGroup);

View File

@ -41,10 +41,10 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh
<FormattedList
value={
Array [
<Memo(Connect(AtMention))
<Memo(Connect(Component))
mentionName="user-0"
/>,
<Memo(Connect(AtMention))
<Memo(Connect(Component))
mentionName="user-1"
/>,
]
@ -123,49 +123,43 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh
key=".0"
value={
Array [
<Memo(Connect(AtMention))
<Memo(Connect(Component))
mentionName="user-0"
/>,
<Memo(Connect(AtMention))
<Memo(Connect(Component))
mentionName="user-1"
/>,
]
}
>
<span>
<Connect(AtMention)
<Connect(Component)
key="user-0"
mentionName="user-0"
>
<AtMention
<Memo(AtMention)
currentUserId="admin1"
disableGroupHighlight={false}
disableHighlight={false}
dispatch={[Function]}
groupsByName={Object {}}
hasMention={false}
mentionName="user-0"
teammateNameDisplay="username"
usersByUsername={Object {}}
/>
</Connect(AtMention)>
</Connect(Component)>
and
<Connect(AtMention)
<Connect(Component)
key="user-1"
mentionName="user-1"
>
<AtMention
<Memo(AtMention)
currentUserId="admin1"
disableGroupHighlight={false}
disableHighlight={false}
dispatch={[Function]}
groupsByName={Object {}}
hasMention={false}
mentionName="user-1"
teammateNameDisplay="username"
usersByUsername={Object {}}
/>
</Connect(AtMention)>
</Connect(Component)>
</span>
</FormattedList>
are guest users and need to first be invited to the team before you can add them to the channel. Once they've joined the team, you can add them to this channel.
@ -221,10 +215,10 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh
<FormattedList
value={
Array [
<Memo(Connect(AtMention))
<Memo(Connect(Component))
mentionName="user-0"
/>,
<Memo(Connect(AtMention))
<Memo(Connect(Component))
mentionName="user-1"
/>,
]
@ -306,49 +300,43 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh
key=".1"
value={
Array [
<Memo(Connect(AtMention))
<Memo(Connect(Component))
mentionName="user-0"
/>,
<Memo(Connect(AtMention))
<Memo(Connect(Component))
mentionName="user-1"
/>,
]
}
>
<span>
<Connect(AtMention)
<Connect(Component)
key="user-0"
mentionName="user-0"
>
<AtMention
<Memo(AtMention)
currentUserId="admin1"
disableGroupHighlight={false}
disableHighlight={false}
dispatch={[Function]}
groupsByName={Object {}}
hasMention={false}
mentionName="user-0"
teammateNameDisplay="username"
usersByUsername={Object {}}
/>
</Connect(AtMention)>
</Connect(Component)>
and
<Connect(AtMention)
<Connect(Component)
key="user-1"
mentionName="user-1"
>
<AtMention
<Memo(AtMention)
currentUserId="admin1"
disableGroupHighlight={false}
disableHighlight={false}
dispatch={[Function]}
groupsByName={Object {}}
hasMention={false}
mentionName="user-1"
teammateNameDisplay="username"
usersByUsername={Object {}}
/>
</Connect(AtMention)>
</Connect(Component)>
</span>
</FormattedList>
to this channel once they are members of the
@ -467,7 +455,7 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh
<AlertBanner
footerMessage={
Array [
<Memo(Connect(AtMention))
<Memo(Connect(Component))
mentionName="user-0"
/>,
" and ",
@ -558,22 +546,19 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh
<div
className="AlertBanner__footerMessage"
>
<Connect(AtMention)
<Connect(Component)
key=".$user-0"
mentionName="user-0"
>
<AtMention
<Memo(AtMention)
currentUserId="admin1"
disableGroupHighlight={false}
disableHighlight={false}
dispatch={[Function]}
groupsByName={Object {}}
hasMention={false}
mentionName="user-0"
teammateNameDisplay="username"
usersByUsername={Object {}}
/>
</Connect(AtMention)>
</Connect(Component)>
and
<SimpleTooltip
content="@user-1, @user-2, @user-3, @user-4, @user-5, @user-6, @user-7, @user-8, @user-9, @user-10"
@ -797,7 +782,7 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh
message={
Array [
"You can add ",
<Memo(Connect(AtMention))
<Memo(Connect(Component))
mentionName="user-0"
/>,
" and ",
@ -891,22 +876,19 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh
className="AlertBanner__message"
>
You can add
<Connect(AtMention)
<Connect(Component)
key=".$user-0"
mentionName="user-0"
>
<AtMention
<Memo(AtMention)
currentUserId="admin1"
disableGroupHighlight={false}
disableHighlight={false}
dispatch={[Function]}
groupsByName={Object {}}
hasMention={false}
mentionName="user-0"
teammateNameDisplay="username"
usersByUsername={Object {}}
/>
</Connect(AtMention)>
</Connect(Component)>
and
<SimpleTooltip
content="@user-1, @user-2, @user-3, @user-4, @user-5, @user-6, @user-7, @user-8, @user-9, @user-10"

View File

@ -8,7 +8,7 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, more t
<Fragment>
<p>
<span>
<Connect(AtMention)
<Connect(Component)
channelId="channel_id"
key="username_1"
mentionName="username_1"
@ -37,7 +37,7 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, more t
id="post_body.check_for_out_of_channel_mentions.link.and"
key="1"
/>
<Connect(AtMention)
<Connect(Component)
channelId="channel_id"
key="username_4"
mentionName="username_4"
@ -69,7 +69,7 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, more t
<Fragment>
<p>
<span>
<Connect(AtMention)
<Connect(Component)
channelId="channel_id"
key="username_1"
mentionName="username_1"
@ -79,7 +79,7 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, more t
>
,
</span>
<Connect(AtMention)
<Connect(Component)
channelId="channel_id"
key="username_2"
mentionName="username_2"
@ -89,7 +89,7 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, more t
>
,
</span>
<Connect(AtMention)
<Connect(Component)
channelId="channel_id"
key="username_3"
mentionName="username_3"
@ -99,7 +99,7 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, more t
id="post_body.check_for_out_of_channel_mentions.link.and"
key="3"
/>
<Connect(AtMention)
<Connect(Component)
channelId="channel_id"
key="username_4"
mentionName="username_4"
@ -130,7 +130,7 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, more t
exports[`components/post_view/PostAddChannelMember should match snapshot, private channel 1`] = `
<Fragment>
<p>
<Connect(AtMention)
<Connect(Component)
channelId="channel_id"
mentionName="username_1"
/>
@ -159,7 +159,7 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, privat
exports[`components/post_view/PostAddChannelMember should match snapshot, public channel 1`] = `
<Fragment>
<p>
<Connect(AtMention)
<Connect(Component)
channelId="channel_id"
mentionName="username_1"
/>
@ -188,7 +188,7 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, public
exports[`components/post_view/PostAddChannelMember should match snapshot, with no-groups usernames 1`] = `
<Fragment>
<p>
<Connect(AtMention)
<Connect(Component)
channelId="channel_id"
mentionName="username_1"
/>
@ -212,7 +212,7 @@ exports[`components/post_view/PostAddChannelMember should match snapshot, with n
/>
</p>
<p>
<Connect(AtMention)
<Connect(Component)
channelId="channel_id"
mentionName="user_id_2"
/>

View File

@ -111,8 +111,8 @@ export default class PostAddChannelMember extends React.PureComponent<Props, Sta
);
}
const otherUsers = [...usernames];
const firstUserName = otherUsers.shift();
const lastUserName = otherUsers.pop();
const firstUserName = otherUsers.shift() as string; // will never be undefined
const lastUserName = otherUsers.pop() as string; // will never be undefined
return (
<span>
<AtMention

View File

@ -8,14 +8,14 @@ exports[`messageHtmlToComponent At mention 1`] = `
<span
data-mention="joram"
>
<Memo(Connect(AtMention))
<Memo(Connect(Component))
disableGroupHighlight={false}
disableHighlight={false}
hasMention={true}
mentionName="joram"
>
@joram
</Memo(Connect(AtMention))>
</Memo(Connect(Component))>
</span>
</span>
</p>
@ -26,14 +26,14 @@ exports[`messageHtmlToComponent At mention 2`] = `
<span
data-mention="joram"
>
<Memo(Connect(AtMention))
<Memo(Connect(Component))
disableGroupHighlight={false}
disableHighlight={true}
hasMention={true}
mentionName="joram"
>
@joram
</Memo(Connect(AtMention))>
</Memo(Connect(Component))>
</span>
</p>
`;
@ -43,14 +43,14 @@ exports[`messageHtmlToComponent At mention with group highlight disabled 1`] = `
<span
data-mention="developers"
>
<Memo(Connect(AtMention))
<Memo(Connect(Component))
disableGroupHighlight={false}
disableHighlight={false}
hasMention={true}
mentionName="developers"
>
@developers
</Memo(Connect(AtMention))>
</Memo(Connect(Component))>
</span>
</p>
`;
@ -60,14 +60,14 @@ exports[`messageHtmlToComponent At mention with group highlight disabled 2`] = `
<span
data-mention="developers"
>
<Memo(Connect(AtMention))
<Memo(Connect(Component))
disableGroupHighlight={true}
disableHighlight={false}
hasMention={true}
mentionName="developers"
>
@developers
</Memo(Connect(AtMention))>
</Memo(Connect(Component))>
</span>
</p>
`;

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {popOverOverlayPosition} from 'utils/position_utils';
import {popOverOverlayPosition, approxGroupPopOverHeight} from 'utils/position_utils';
test('Should return placement position for overlay based on bounds, space required and innerHeight', () => {
const targetBounds = {
@ -14,3 +14,28 @@ test('Should return placement position for overlay based on bounds, space requir
expect(popOverOverlayPosition(targetBounds as DOMRect, 1000, 450)).toEqual('bottom');
expect(popOverOverlayPosition(targetBounds as DOMRect, 1000, 600)).toEqual('left');
});
test('Should return the correct height for the group list overlay bounded by viewport height or max list height', () => {
// constants. should not need to change
const viewportScaleFactor = 0.4;
const headerHeight = 130;
const maxListHeight = 800;
// array of [listHeight, viewPortHeight, expected]
// tests for cases when
// group list fits
// group list is too tall for viewport
// group list reaches max list height
const testCases = [[100, 1000, 230], [500, 500, 330], [800, 2000, maxListHeight]];
for (const [listHeight, viewPortHeight, expected] of testCases) {
expect(
approxGroupPopOverHeight(
listHeight,
viewPortHeight,
viewportScaleFactor,
headerHeight,
maxListHeight,
)).toBe(expected);
}
});

View File

@ -19,3 +19,17 @@ export function popOverOverlayPosition(
}
return placement;
}
export function approxGroupPopOverHeight(
groupListHeight: number,
viewPortHeight: number,
viewportScaleFactor: number,
headerHeight: number,
maxListHeight: number,
): number {
return Math.min(
(viewPortHeight * viewportScaleFactor) + headerHeight,
groupListHeight + headerHeight,
maxListHeight,
);
}

View File

@ -1118,3 +1118,61 @@ describe('PostUtils.getPostURL', () => {
expect(PostUtils.getPostURL(state, postCase)).toBe(expected);
});
});
describe('PostUtils.getMentionDetails', () => {
const user1 = TestHelper.getUserMock({username: 'user1'});
const user2 = TestHelper.getUserMock({username: 'user2'});
const users = {user1, user2};
test.each([
['user1 data from mention', 'user1', user1],
['user2 data from mention', 'user2', user2],
['user1 data from mention with punctution', 'user1.', user1],
['blank string when no matching user', 'user3', undefined],
])('should return %s', (description, mention, expected) => {
expect(PostUtils.getMentionDetails(users, mention)).toEqual(expected);
});
const group1 = TestHelper.getGroupMock({name: 'group1'});
const group2 = TestHelper.getGroupMock({name: 'group2'});
const groups = {group1, group2};
test.each([
['group1 data from mention', 'group1', group1],
['group2 data from mention', 'group2', group2],
['group1 data from mention with punctuation', 'group2.', group2],
['blank string when no matching group', 'group3', undefined],
])('shoud return %s', (description, mention, expected) => {
expect(PostUtils.getMentionDetails(groups, mention)).toEqual(expected);
});
});
describe('PostUtils.getUserOrGroupFromMentionName', () => {
const userMention = 'user1';
const groupMention = 'group1';
const userAndGroupMention = 'user2';
const user1 = TestHelper.getUserMock({username: 'user1'});
const user2 = TestHelper.getUserMock({username: 'user2'});
const users = {user1, user2};
const group1 = TestHelper.getGroupMock({name: 'group1'});
const group2 = TestHelper.getGroupMock({name: 'user2'});
const groups = {group1, user2: group2};
test.each([
['the found user', userMention, false, [user1, undefined]],
['nothing when not matching user or group', 'user3', false, [undefined, undefined]],
['the found group', groupMention, false, [undefined, group1]],
['no group when groups highlights are disabled', groupMention, true, [undefined, undefined]],
['user when there is a matching user and group mention', userAndGroupMention, false, [user2, undefined]],
])('should return %s', (description, mention, disabledGroups, expected) => {
const result = PostUtils.getUserOrGroupFromMentionName(
mention,
users,
groups,
disabledGroups,
(usersOrGroups, mention) => usersOrGroups[mention],
);
expect(result).toEqual(expected);
});
});

View File

@ -434,7 +434,7 @@ export function makeGetMentionsFromMessage(): (state: GlobalState, post: Post) =
const mentionsArray = post.message.match(Constants.MENTIONS_REGEX) || [];
for (let i = 0; i < mentionsArray.length; i++) {
const mention = mentionsArray[i];
const user = getUserOrGroupFromMentionName(users, mention.substring(1)) as UserProfile | '';
const user = getMentionDetails(users, mention.substring(1)) as UserProfile | '';
if (user) {
mentions[mention] = user;
@ -704,7 +704,7 @@ export function makeGetUniqueReactionsToPost(): (state: GlobalState, postId: Pos
);
}
export function getUserOrGroupFromMentionName(usersByUsername: Record<string, UserProfile | Group>, mentionName: string) {
export function getMentionDetails(usersByUsername: Record<string, UserProfile | Group>, mentionName: string): UserProfile | Group | undefined {
let mentionNameToLowerCase = mentionName.toLowerCase();
while (mentionNameToLowerCase.length > 0) {
@ -720,7 +720,29 @@ export function getUserOrGroupFromMentionName(usersByUsername: Record<string, Us
}
}
return '';
return undefined;
}
export function getUserOrGroupFromMentionName(
mentionName: string,
users: Record<string, UserProfile>,
groups: Record<string, Group>,
groupsDisabled?: boolean,
getMention = getMentionDetails,
): [UserProfile?, Group?] {
const user = getMention(users, mentionName) as UserProfile | undefined;
// prioritizes user if user exists with the same name as a group.
if (!user && !groupsDisabled) {
const group = getMention(groups, mentionName) as Group | undefined;
if (group && !group.allow_reference) {
return [undefined, undefined]; // remove group mention if not allowed to reference
}
return [undefined, group];
}
return [user, undefined];
}
export function mentionsMinusSpecialMentionsInText(message: string) {
@ -736,10 +758,6 @@ export function mentionsMinusSpecialMentionsInText(message: string) {
return mentions;
}
function isUserProfile(entity: UserProfile | Group): entity is UserProfile {
return (entity as UserProfile).username !== undefined;
}
export function makeGetUserOrGroupMentionCountFromMessage(): (state: GlobalState, message: Post['message']) => number {
return createSelector(
'getUserOrGroupMentionCountFromMessage',
@ -751,15 +769,12 @@ export function makeGetUserOrGroupMentionCountFromMessage(): (state: GlobalState
const markdownCleanedText = formatWithRenderer(message, new MentionableRenderer());
const mentions = new Set(markdownCleanedText.match(Constants.MENTIONS_REGEX) || []);
mentions.forEach((mention) => {
const data = {...groups, ...users};
const userOrGroup = getUserOrGroupFromMentionName(data, mention.substring(1));
const [user, group] = getUserOrGroupFromMentionName(mention.substring(1), users, groups);
if (userOrGroup) {
if (isUserProfile(userOrGroup)) {
count++;
} else {
count += userOrGroup.member_count;
}
if (user) {
count++;
} else if (group) {
count += group.member_count;
}
});
return count;