Skip to content
Snippets Groups Projects
Unverified Commit 5964854f authored by Sameer Al-Sakran's avatar Sameer Al-Sakran Committed by GitHub
Browse files

Merge pull request #6308 from metabase/issue-5742

Issue 5742
parents 92f8a500 bbd99860
No related branches found
No related tags found
No related merge requests found
/* eslint "react/prop-types": "warn" */
import React, { Component } from "react";
import PropTypes from "prop-types";
import ReactDOM from "react-dom";
import { findDOMNode } from "react-dom";
import _ from "underscore";
import cx from "classnames";
import Icon from "metabase/components/Icon.jsx";
import Popover from "metabase/components/Popover.jsx";
import UserAvatar from "metabase/components/UserAvatar.jsx";
import OnClickOutsideWrapper from 'metabase/components/OnClickOutsideWrapper';
import Icon from "metabase/components/Icon";
import Input from "metabase/components/Input";
import Popover from "metabase/components/Popover";
import UserAvatar from "metabase/components/UserAvatar";
import MetabaseAnalytics from "metabase/lib/analytics";
import { KEYCODE_ESCAPE, KEYCODE_COMMA, KEYCODE_TAB, KEYCODE_UP, KEYCODE_DOWN, KEYCODE_BACKSPACE } from "metabase/lib/keyboard";
import _ from "underscore";
import cx from "classnames";
import {
KEYCODE_ESCAPE,
KEYCODE_ENTER,
KEYCODE_COMMA,
KEYCODE_TAB,
KEYCODE_UP,
KEYCODE_DOWN,
KEYCODE_BACKSPACE
} from "metabase/lib/keyboard";
const VALID_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default class RecipientPicker extends Component {
constructor(props, context) {
super(props, context);
constructor(props) {
super(props);
this.state = {
inputValue: "",
filteredUsers: [],
selectedUser: null,
selectedUserID: null,
focused: props.recipients.length === 0
};
_.bindAll(this, "onMouseDownCapture", "onInputChange", "onInputKeyDown", "onInputFocus", "onInputBlur");
}
// TODO: use recipientTypes to limit the type of recipient that can be added
......@@ -44,8 +53,12 @@ export default class RecipientPicker extends Component {
};
setInputValue(inputValue) {
let { users, recipients } = this.props;
let { selectedUser } = this.state;
const { users, recipients } = this.props;
const searchString = inputValue.toLowerCase()
let { selectedUserID } = this.state;
let filteredUsers = [];
let recipientsById = {};
for (let recipient of recipients) {
......@@ -54,54 +67,72 @@ export default class RecipientPicker extends Component {
}
}
let filteredUsers = [];
if (inputValue) {
// case insensitive search of name or email
let inputValueLower = inputValue.toLowerCase()
filteredUsers = users.filter(user =>
// filter out users who have already been selected
!(user.id in recipientsById) &&
(user.common_name.toLowerCase().indexOf(inputValueLower) >= 0 || user.email.toLowerCase().indexOf(inputValueLower) >= 0)
(
user.common_name.toLowerCase().indexOf(searchString) >= 0 ||
user.email.toLowerCase().indexOf(searchString) >= 0
)
);
}
if (selectedUser == null || !_.find(filteredUsers, (u) => u.id === selectedUser)) {
if (selectedUserID == null || !_.find(filteredUsers, (user) => user.id === selectedUserID)) {
// if there are results based on the user's typing...
if (filteredUsers.length > 0) {
selectedUser = filteredUsers[0].id;
// select the first user in the list and set the ID to that
selectedUserID = filteredUsers[0].id;
} else {
selectedUser = null;
selectedUserID = null;
}
}
this.setState({ inputValue, filteredUsers, selectedUser });
this.setState({
inputValue,
filteredUsers,
selectedUserID
});
}
onInputChange(e) {
this.setInputValue(e.target.value);
onInputChange = ({ target }) => {
this.setInputValue(target.value);
}
onInputKeyDown(e) {
// capture events on the input to allow for convenient keyboard shortcuts
onInputKeyDown = (event) => {
const keyCode = event.keyCode
const { filteredUsers, selectedUserID } = this.state
// enter, tab, comma
if (e.keyCode === KEYCODE_ESCAPE || e.keyCode === KEYCODE_TAB || e.keyCode === KEYCODE_COMMA) {
if (keyCode === KEYCODE_ESCAPE || keyCode === KEYCODE_TAB || keyCode === KEYCODE_COMMA || keyCode === KEYCODE_ENTER) {
this.addCurrentRecipient();
}
// up arrow
else if (e.keyCode === KEYCODE_UP) {
e.preventDefault();
let index = _.findIndex(this.state.filteredUsers, (u) => u.id === this.state.selectedUser);
else if (event.keyCode === KEYCODE_UP) {
event.preventDefault();
let index = _.findIndex(filteredUsers, (u) => u.id === selectedUserID);
if (index > 0) {
this.setState({ selectedUser: this.state.filteredUsers[index - 1].id });
this.setState({ selectedUserID: filteredUsers[index - 1].id });
}
}
// down arrow
else if (e.keyCode === KEYCODE_DOWN) {
e.preventDefault();
let index = _.findIndex(this.state.filteredUsers, (u) => u.id === this.state.selectedUser);
if (index >= 0 && index < this.state.filteredUsers.length - 1) {
this.setState({ selectedUser: this.state.filteredUsers[index + 1].id });
else if (keyCode === KEYCODE_DOWN) {
event.preventDefault();
let index = _.findIndex(filteredUsers, (u) => u.id === selectedUserID);
if (index >= 0 && index < filteredUsers.length - 1) {
this.setState({ selectedUserID: filteredUsers[index + 1].id });
}
}
// backspace
else if (e.keyCode === KEYCODE_BACKSPACE) {
else if (keyCode === KEYCODE_BACKSPACE) {
let { recipients } = this.props;
if (!this.state.inputValue && recipients.length > 0) {
this.removeRecipient(recipients[recipients.length - 1])
......@@ -109,17 +140,17 @@ export default class RecipientPicker extends Component {
}
}
onInputFocus(e) {
onInputFocus = () => {
this.setState({ focused: true });
}
onInputBlur(e) {
onInputBlur = () => {
this.addCurrentRecipient();
this.setState({ focused: false });
}
onMouseDownCapture(e) {
let input = ReactDOM.findDOMNode(this.refs.input);
onMouseDownCapture = (e) => {
let input = findDOMNode(this.refs.input);
input.focus();
// prevents clicks from blurring input while still allowing text selection:
if (input !== e.target) {
......@@ -128,8 +159,8 @@ export default class RecipientPicker extends Component {
}
addCurrentRecipient() {
let input = ReactDOM.findDOMNode(this.refs.input);
let user = _.find(this.state.filteredUsers, (u) => u.id === this.state.selectedUser);
let input = findDOMNode(this.refs.input);
let user = _.find(this.state.filteredUsers, (u) => u.id === this.state.selectedUserID);
if (user) {
this.addRecipient(user);
} else if (VALID_EMAIL_REGEX.test(input.value)) {
......@@ -137,72 +168,101 @@ export default class RecipientPicker extends Component {
}
}
addRecipient(recipient) {
addRecipient = (recipient) => {
const { recipients } = this.props
// recipient is a user object, or plain object containing "email" key
this.props.onRecipientsChange(this.props.recipients.concat(recipient));
this.props.onRecipientsChange(
// return the list of recipients with the new user added
recipients.concat(recipient)
);
// reset the input value
this.setInputValue("");
MetabaseAnalytics.trackEvent((this.props.isNewPulse) ? "PulseCreate" : "PulseEdit", "AddRecipient", (recipient.id) ? "user" : "email");
MetabaseAnalytics.trackEvent(
(this.props.isNewPulse) ? "PulseCreate" : "PulseEdit",
"AddRecipient",
(recipient.id) ? "user" : "email"
);
}
removeRecipient(recipient) {
this.props.onRecipientsChange(this.props.recipients.filter(r => recipient.id != null ? recipient.id !== r.id : recipient.email !== r.email));
const { recipients, onRecipientsChange } = this.props
onRecipientsChange(
recipients.filter(r =>
recipient.id != null
? recipient.id !== r.id
: recipient.email !== r.email
)
);
MetabaseAnalytics.trackEvent((this.props.isNewPulse) ? "PulseCreate" : "PulseEdit", "RemoveRecipient", (recipient.id) ? "user" : "email");
MetabaseAnalytics.trackEvent(
(this.props.isNewPulse) ? "PulseCreate" : "PulseEdit",
"RemoveRecipient",
(recipient.id) ? "user" : "email"
);
}
render() {
let { filteredUsers, selectedUser } = this.state;
let { recipients } = this.props;
const { filteredUsers, inputValue, focused, selectedUserID } = this.state;
const { recipients } = this.props;
return (
<ul className={cx("px1 pb1 bordered rounded flex flex-wrap bg-white", { "input--focus": this.state.focused })} onMouseDownCapture={this.onMouseDownCapture}>
{recipients.map((recipient, index) =>
<li key={index} className="mr1 py1 pl1 mt1 rounded bg-grey-1">
<span className="h4 text-bold">{recipient.common_name || recipient.email}</span>
<a className="text-grey-2 text-grey-4-hover px1" onClick={this.removeRecipient.bind(this, recipient)}>
<Icon name="close" className="" size={12} />
</a>
<OnClickOutsideWrapper handleDismissal={() => {
this.setState({ focused: false });
}}>
<ul className={cx("px1 pb1 bordered rounded flex flex-wrap bg-white", { "input--focus": this.state.focused })} onMouseDownCapture={this.onMouseDownCapture}>
{recipients.map((recipient, index) =>
<li key={recipient.id} className="mr1 py1 pl1 mt1 rounded bg-grey-1">
<span className="h4 text-bold">{recipient.common_name || recipient.email}</span>
<a
className="text-grey-2 text-grey-4-hover px1"
onClick={() => this.removeRecipient(recipient)}
>
<Icon name="close" className="" size={12} />
</a>
</li>
)}
<li className="flex-full mr1 py1 pl1 mt1 bg-white" style={{ "minWidth": " 100px" }}>
<Input
ref="input"
className="full h4 text-bold text-default no-focus borderless"
placeholder={recipients.length === 0 ? "Enter email addresses you'd like this data to go to" : null}
value={inputValue}
autoFocus={focused}
onKeyDown={this.onInputKeyDown}
onChange={this.onInputChange}
onFocus={this.onInputFocus}
onBlurChange={this.onInputBlur}
/>
<Popover
isOpen={filteredUsers.length > 0}
hasArrow={false}
tetherOptions={{
attachment: "top left",
targetAttachment: "bottom left",
targetOffset: "10 0"
}}
>
<ul className="py1">
{filteredUsers.map(user =>
<li
key={user.id}
className={cx(
"py1 px2 flex align-center text-bold bg-brand-hover text-white-hover", {
"bg-grey-1": user.id === selectedUserID
})}
onClick={() => this.addRecipient(user)}
>
<span className="text-white"><UserAvatar user={user} /></span>
<span className="ml1 h4">{user.common_name}</span>
</li>
)}
</ul>
</Popover>
</li>
)}
<li className="flex-full mr1 py1 pl1 mt1 bg-white" style={{ "minWidth": " 100px" }}>
<input
ref="input"
type="text"
className="full h4 text-bold text-default no-focus borderless"
placeholder={recipients.length === 0 ? "Enter email addresses you'd like this data to go to" : null}
value={this.state.inputValue}
autoFocus={this.state.focused}
onKeyDown={this.onInputKeyDown}
onChange={this.onInputChange}
onFocus={this.onInputFocus}
onBlur={this.onInputBlur}
/>
<Popover
isOpen={filteredUsers.length > 0}
hasArrow={false}
tetherOptions={{
attachment: "top left",
targetAttachment: "bottom left",
targetOffset: "10 0"
}}
>
<ul className="py1">
{filteredUsers.map(user =>
<li
className={cx("py1 px2 flex align-center text-bold bg-brand-hover text-white-hover", {
"bg-grey-1": user.id === selectedUser
})}
onClick={this.addRecipient.bind(this, user)}
>
<span className="text-white"><UserAvatar user={user} /></span>
<span className="ml1 h4">{user.common_name}</span>
</li>
)}
</ul>
</Popover>
</li>
</ul>
</ul>
</OnClickOutsideWrapper>
);
}
}
import React from 'react'
import { shallow } from 'enzyme'
import {
KEYCODE_DOWN,
KEYCODE_TAB,
KEYCODE_ENTER,
KEYCODE_COMMA
} from "metabase/lib/keyboard"
import Input from "metabase/components/Input"
import UserAvatar from 'metabase/components/UserAvatar'
import RecipientPicker from 'metabase/pulse/components/RecipientPicker'
// We have to do some mocking here to avoid calls to GA and to Metabase settings
jest.mock('metabase/lib/settings', () => ({
get: () => 'v'
}))
global.ga = jest.fn()
const TEST_USERS = [
{ id: 1, common_name: 'Barb', email: 'barb_holland@hawkins.mail' }, // w
{ id: 2, common_name: 'Dustin', email: 'dustin_henderson@hawkinsav.club' }, // w
{ id: 3, common_name: 'El', email: '011@energy.gov' },
{ id: 4, common_name: 'Lucas', email: 'lucas.sinclair@hawkins.mail' }, // w
{ id: 5, common_name: 'Mike', email: 'dm_mike@hawkins.mail' }, // w
{ id: 6, common_name: 'Nancy', email: '' },
{ id: 7, common_name: 'Steve', email: '' },
{ id: 8, common_name: 'Will', email: 'zombieboy@upside.down' }, // w
]
describe('recipient picker', () => {
describe('focus', () => {
it('should be focused if there are no recipients', () => {
const wrapper = shallow(
<RecipientPicker
recipients={[]}
users={TEST_USERS}
isNewPulse={true}
onRecipientsChange={() => alert('why?')}
/>
)
expect(wrapper.state().focused).toBe(true)
})
it('should not be focused if there are existing recipients', () => {
const wrapper = shallow(
<RecipientPicker
recipients={[TEST_USERS[0]]}
users={TEST_USERS}
isNewPulse={true}
onRecipientsChange={() => alert('why?')}
/>
)
expect(wrapper.state().focused).toBe(false)
})
})
describe('filtering', () => {
it('should properly filter users based on input', () => {
const wrapper = shallow(
<RecipientPicker
recipients={[]}
users={TEST_USERS}
isNewPulse={true}
onRecipientsChange={() => alert('why?')}
/>
)
const spy = jest.spyOn(wrapper.instance(), 'setInputValue')
const input = wrapper.find(Input)
// we should start off with no users
expect(wrapper.state().filteredUsers.length).toBe(0)
// simulate typing 'w'
input.simulate('change', { target: { value: 'w' }})
expect(spy).toHaveBeenCalled()
expect(wrapper.state().inputValue).toEqual('w')
// 5 of the test users have a w in their name or email
expect(wrapper.state().filteredUsers.length).toBe(5)
})
})
describe('recipient selection', () => {
it('should allow the user to click to select a recipient', () => {
const spy = jest.fn()
const wrapper = shallow(
<RecipientPicker
recipients={[]}
users={TEST_USERS}
isNewPulse={true}
onRecipientsChange={spy}
/>
)
const input = wrapper.find(Input)
// limit our options to one user by typing
input.simulate('change', { target: { value: 'steve' }})
expect(wrapper.state().filteredUsers.length).toBe(1)
const user = wrapper.find(UserAvatar).closest('li')
user.simulate('click', { target: {}})
expect(spy).toHaveBeenCalled()
})
describe('key selection', () => {
[KEYCODE_TAB, KEYCODE_ENTER, KEYCODE_COMMA].map(key =>
it(`should allow the user to use arrow keys and then ${key} to select a recipient`, () => {
const spy = jest.fn()
const wrapper = shallow(
<RecipientPicker
recipients={[]}
users={TEST_USERS}
isNewPulse={true}
onRecipientsChange={spy}
/>
)
const input = wrapper.find(Input)
// limit our options to user by typing
input.simulate('change', { target: { value: 'w' }})
// the initially selected user should be the first user
expect(wrapper.state().selectedUserID).toBe(TEST_USERS[0].id)
input.simulate('keyDown', {
keyCode: KEYCODE_DOWN,
preventDefault: jest.fn()
})
// the next possible user should be selected now
expect(wrapper.state().selectedUserID).toBe(TEST_USERS[1].id)
input.simulate('keydown', {
keyCode: key,
preventDefalut: jest.fn()
})
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith([TEST_USERS[1]])
})
)
})
describe('usage', () => {
it('should all work good', () => {
class TestComponent extends React.Component {
state = {
recipients: []
}
render () {
const { recipients } = this.state
return (
<RecipientPicker
recipients={recipients}
users={TEST_USERS}
isNewPulse={true}
onRecipientsChange={recipients => {
this.setState({ recipients })
}}
/>
)
}
}
const wrapper = shallow(<TestComponent />)
// something about the popover code makes it not work with mount
// in the test env, so we have to use shallow and dive here to
// actually get the selection list to render anything that we
// can interact with
const picker = wrapper.find(RecipientPicker).dive()
const input = picker.find(Input)
input.simulate('change', { target: { value: 'will' }})
const user = picker.find(UserAvatar).closest('li')
user.simulate('click', { target: {}})
// there should be one user selected
expect(wrapper.state().recipients.length).toBe(1)
// grab the updated state of RecipientPicker
const postAddPicker = wrapper.find(RecipientPicker).dive()
// there should only be one user in the picker now , "Will" and then the input
// so there will be two list items
expect(postAddPicker.find('li').length).toBe(2)
})
})
})
})
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment