Skip to content
Snippets Groups Projects
Commit ebd662db authored by Tom Robinson's avatar Tom Robinson
Browse files

Implement editing of Pulse channel recipients and other fields

parent cc932340
No related branches found
No related tags found
No related merge requests found
......@@ -29,11 +29,11 @@ export default class UserAvatar extends Component {
let initials = '??';
if (first_name !== 'undefined') {
initials = first_name.substring(0, 1);
initials = first_name.substring(0, 1).toUpperCase();
}
if (last_name !== 'undefined') {
initials = initials + last_name.substring(0, 1);
initials = initials + last_name.substring(0, 1).toUpperCase();
}
return initials;
......
......@@ -11,6 +11,7 @@
border-radius: var(--input-border-radius);
}
.input--focus,
.input:focus {
outline: none;
border: 1px solid var(--input-border-active-color);
......@@ -21,3 +22,7 @@
opacity: .5;
cursor: not-allowed;
}
.no-focus:focus {
outline: 0;
}
......@@ -7,12 +7,14 @@ import moment from "moment";
const card = new Schema('card');
const pulse = new Schema('pulse');
const user = new Schema('user');
// pulse.define({
// cards: arrayOf(card)
// });
const Pulse = new AngularResourceProxy("Pulse", ["list", "get", "create", "update"]);
const Card = new AngularResourceProxy("Card", ["list"]);
const User = new AngularResourceProxy("User", ["list"]);
export const FETCH_PULSES = 'FETCH_PULSES';
export const SET_EDITING_PULSE = 'SET_EDITING_PULSE';
......@@ -20,6 +22,7 @@ export const UPDATE_EDITING_PULSE = 'UPDATE_EDITING_PULSE';
export const SAVE_EDITING_PULSE = 'SAVE_EDITING_PULSE';
export const FETCH_CARDS = 'FETCH_CARDS';
export const FETCH_USERS = 'FETCH_USERS';
export const fetchPulses = createThunkAction(FETCH_PULSES, function() {
return async function(dispatch, getState) {
......@@ -67,3 +70,17 @@ export const fetchCards = createThunkAction(FETCH_CARDS, function(filterMode = "
return normalize(cards, arrayOf(card));
};
});
// NOTE: duplicated from admin/people/actions.js
export const fetchUsers = createThunkAction(FETCH_USERS, function() {
return async function(dispatch, getState) {
let users = await User.list();
for (var u of users) {
u.date_joined = (u.date_joined) ? moment(u.date_joined) : null;
u.last_login = (u.last_login) ? moment(u.last_login) : null;
}
return normalize(users, arrayOf(user));
};
});
......@@ -10,7 +10,8 @@ import {
setEditingPulse,
updateEditingPulse,
saveEditingPulse,
fetchCards
fetchCards,
fetchUsers
} from "../actions";
import _ from "underscore";
......@@ -30,6 +31,7 @@ export default class PulseEdit extends Component {
componentDidMount() {
this.props.dispatch(setEditingPulse(this.props.pulseId));
this.props.dispatch(fetchCards());
this.props.dispatch(fetchUsers());
}
async save() {
......
......@@ -43,7 +43,7 @@ export default class PulseEditCard extends Component {
<p>Pick up to five questions you'd like to send in this pulse</p>
<ol className="my3">
{cards && pulseCards.map((card, index) =>
<li className="my1 flex align-top" style={{ width: "400px" }}>
<li key={index} className="my1 flex align-top" style={{ width: "400px" }}>
<span className="h3 text-bold mr1 mt1">{index + 1}.</span>
{ card ?
<PulseCardPreview card={card} onRemove={this.removeCard.bind(this, index)} />
......
import React, { Component, PropTypes } from "react";
import RecipientPicker from "./RecipientPicker.jsx";
import SchedulePicker from "./SchedulePicker.jsx";
import Select from "metabase/components/Select.jsx";
import Toggle from "metabase/components/Toggle.jsx";
import { capitalize } from "metabase/lib/formatting";
import _ from "underscore";
const CHANNELS = [
{
type: "email",
name: "Email",
recipients: ["account", "email"],
recipients: ["user", "email"],
schedules: ["daily", "weekly"]
},
{
......@@ -31,25 +32,6 @@ const CHANNELS = [
}
];
const HOUR_OPTIONS = _.times(12, (n) => (
{ name: (n === 0 ? 12 : n)+":00", value: n }
));
const AM_PM_OPTIONS = [
{ name: "AM", value: 0 },
{ name: "PM", value: 1 }
];
const DAY_OF_WEEK_OPTIONS = [
{ name: "Sunday", value: "sun" },
{ name: "Monday", value: "mon" },
{ name: "Tuesday", value: "tue" },
{ name: "Wednesday", value: "wed" },
{ name: "Thursday", value: "thu" },
{ name: "Friday", value: "fri" },
{ name: "Saturday", value: "sat" }
];
export default class PulseEditChannel extends Component {
constructor(props) {
super(props);
......@@ -67,10 +49,21 @@ export default class PulseEditChannel extends Component {
return;
}
let details = {};
if (channelSpec.fields) {
for (let field of channelSpec.fields) {
if (field.required) {
if (field.type === "select") {
details[field.name] = field.options[0];
}
}
}
}
let channel = {
channel_type: type,
recipients: [],
details: {},
details: details,
schedule_type: channelSpec.schedules[0],
schedule_details: { day_of_week: "mon", hour_of_day: 8 }
};
......@@ -99,64 +92,50 @@ export default class PulseEditChannel extends Component {
}
}
renderDayPicker(c, index) {
renderFields(channel, index, channelSpec) {
return (
<span className="mt1">
<span className="mx1">on</span>
<Select
value={_.find(DAY_OF_WEEK_OPTIONS, (o) => o.value === c.schedule_details.day_of_week)}
options={DAY_OF_WEEK_OPTIONS}
optionNameFn={o => o.name}
optionValueFn={o => o.value}
onChange={(o) => this.onChannelPropertyChange(index, "schedule_details", { ...c.schedule_details, day_of_week: o }) }
/>
</span>
);
}
renderHourPicker(c, index) {
let hourOfDay = isNaN(c.schedule_details.hour_of_day) ? 8 : c.schedule_details.hour_of_day;
let hour = hourOfDay % 12;
let amPm = hourOfDay >= 12 ? 1 : 0;
return (
<div className="mt1">
<span className="mr1">at</span>
<Select
className="mr1"
value={_.find(HOUR_OPTIONS, (o) => o.value === hour)}
options={HOUR_OPTIONS}
optionNameFn={o => o.name}
optionValueFn={o => o.value}
onChange={(o) => this.onChannelPropertyChange(index, "schedule_details", { ...c.schedule_details, hour_of_day: o + amPm * 12 }) }
/>
<Select
value={_.find(AM_PM_OPTIONS, (o) => o.value === amPm)}
options={AM_PM_OPTIONS}
optionNameFn={o => o.name}
optionValueFn={o => o.value}
onChange={(o) => this.onChannelPropertyChange(index, "schedule_details", { ...c.schedule_details, hour_of_day: hour + o * 12 }) }
/>
<div>
{channelSpec.fields.map(field =>
<div>
<span className="h4 text-bold mr1">{field.displayName}</span>
{ field.type === "select" ?
<Select
className="h4 text-bold"
value={channel.details[field.name]}
options={field.options}
optionNameFn={o => o}
optionValueFn={o => o}
onChange={(o) => this.onChannelPropertyChange(index, "details", { ...channel.details, [field.name]: o })}
/>
: null }
</div>
)}
</div>
);
)
}
renderChannel(channel, index, channelSpec) {
return (
<li className="py1">
<span className="mr1">Sent</span>
<Select
value={channel.schedule_type}
options={channelSpec.schedules}
optionNameFn={o => capitalize(o)}
optionValueFn={o => o}
onChange={(o) => this.onChannelPropertyChange(index, "schedule_type", o)}
/>
{ channel.schedule_type === "weekly" &&
this.renderDayPicker(channel, index)
<li key={index} className="py1">
{ channelSpec.recipients &&
<div>
<div className="h4 text-bold mb1">To:</div>
<RecipientPicker
recipients={channel.recipients}
recipientTypes={channelSpec.recipients}
users={this.props.userList}
onRecipientsChange={(recipients) => this.onChannelPropertyChange(index, "recipients", recipients)}
/>
</div>
}
{ (channel.schedule_type === "daily" || channel.schedule_type === "weekly") &&
this.renderHourPicker(channel, index)
{ channelSpec.fields &&
this.renderFields(channel, index, channelSpec)
}
<SchedulePicker
channel={channel}
channelSpec={channelSpec}
onPropertyChange={this.onChannelPropertyChange.bind(this, index)}
/>
</li>
)
}
......@@ -166,7 +145,7 @@ export default class PulseEditChannel extends Component {
return (
<li key={channelSpec.type} className="py2 border-row-divider">
<div className="flex align-center">
<h3>{channelSpec.name}</h3>
<h2>{channelSpec.name}</h2>
<Toggle className="flex-align-right" value={pulse.channels.some(c => c.channel_type === channelSpec.type)} onChange={this.toggleChannel.bind(this, channelSpec.type)} />
</div>
<ul>
......
import React, { Component, PropTypes } from "react";
import Icon from "metabase/components/Icon.jsx";
import Popover from "metabase/components/Popover.jsx";
import UserAvatar from "metabase/components/UserAvatar.jsx";
import _ from "underscore";
import cx from "classnames";
const VALID_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default class RecipientPicker extends Component {
constructor(props, context) {
super(props, context);
this.state = {
inputValue: "",
filteredUsers: [],
selectedUser: null
};
_.bindAll(this, "onMouseDownCapture", "onInputChange", "onInputKeyDown", "onInputFocus", "onInputBlur");
}
// TODO: use recipientTypes to limit the type of recipient that can be added
static propTypes = {
recipients: PropTypes.array,
recipientTypes: PropTypes.array.isRequired,
onRecipientsChange: PropTypes.func.isRequired,
users: PropTypes.array
};
static defaultProps = {
recipientTypes: ["user", "email"]
};
setInputValue(inputValue) {
let { users, recipients } = this.props;
let { selectedUser } = this.state;
let recipientsById = {};
for (let recipient of recipients) {
if (recipient.id != null) {
recipientsById[recipient.id] = recipient;
}
}
let filteredUsers = [];
if (inputValue) {
// case insensitive search of name or email
let inputValueLower = inputValue.toLowerCase()
filteredUsers = users.filter(user =>
!(user.id in recipientsById) &&
(user.common_name.toLowerCase().indexOf(inputValueLower) >= 0 || user.email.toLowerCase().indexOf(inputValueLower) >= 0)
);
}
if (selectedUser == null || !_.find(filteredUsers, (u) => u.id === selectedUser)) {
if (filteredUsers.length > 0) {
selectedUser = filteredUsers[0].id;
} else {
selectedUser = null;
}
}
this.setState({ inputValue, filteredUsers, selectedUser });
}
onInputChange(e) {
this.setInputValue(e.target.value);
}
onInputKeyDown(e) {
// enter
if (e.keyCode === 13) {
let user = _.find(this.state.filteredUsers, (u) => u.id === this.state.selectedUser);
if (user) {
this.addRecipient(user);
} else if (VALID_EMAIL_REGEX.test(e.target.value)) {
this.addRecipient({ email: e.target.value });
}
}
// up arrow
else if (e.keyCode === 38) {
e.preventDefault();
let index = _.findIndex(this.state.filteredUsers, (u) => u.id === this.state.selectedUser);
if (index > 0) {
this.setState({ selectedUser: this.state.filteredUsers[index - 1].id });
}
}
// down arrow
else if (e.keyCode === 40) {
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 });
}
}
// backspace
else if (e.keyCode === 8) {
let { recipients } = this.props;
if (!this.state.inputValue && recipients.length > 0) {
this.removeRecipient(recipients[recipients.length - 1])
}
}
}
onInputFocus(e) {
this.setState({ focused: true });
}
onInputBlur(e) {
this.setState({ focused: false });
}
onMouseDownCapture(e) {
let input = React.findDOMNode(this.refs.input);
input.focus();
// prevents clicks from blurring input while still allowing text selection:
if (input !== e.target) {
e.preventDefault();
}
}
addRecipient(recipient) {
// recipient is a user object, or plain object containing "email" key
this.props.onRecipientsChange(this.props.recipients.concat(recipient));
this.setInputValue("");
}
removeRecipient(recipient) {
this.props.onRecipientsChange(this.props.recipients.filter(r => recipient.id != null ? recipient.id !== r.id : recipient.email !== r.email));
}
render() {
let { filteredUsers, selectedUser } = this.state;
return (
<ul className={cx("px1 pb1 bordered rounded", { "input--focus": this.state.focused })} onMouseDownCapture={this.onMouseDownCapture}>
{this.props.recipients.map((recipient, index) =>
<li key={index} className="inline-block 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="" width={12} height={12} />
</a>
</li>
)}
<li className="inline-block mr1 py1 pl1 mt1">
<input
ref="input"
type="text"
className="h4 text-bold text-default no-focus borderless"
autoFocus
value={this.state.inputValue}
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>
);
}
}
import React, { Component, PropTypes } from "react";
import Select from "metabase/components/Select.jsx";
import { capitalize } from "metabase/lib/formatting";
import _ from "underscore";
const HOUR_OPTIONS = _.times(12, (n) => (
{ name: (n === 0 ? 12 : n)+":00", value: n }
));
const AM_PM_OPTIONS = [
{ name: "AM", value: 0 },
{ name: "PM", value: 1 }
];
const DAY_OF_WEEK_OPTIONS = [
{ name: "Sunday", value: "sun" },
{ name: "Monday", value: "mon" },
{ name: "Tuesday", value: "tue" },
{ name: "Wednesday", value: "wed" },
{ name: "Thursday", value: "thu" },
{ name: "Friday", value: "fri" },
{ name: "Saturday", value: "sat" }
];
export default class SchedulePicker extends Component {
static propTypes = {
channel: PropTypes.object.isRequired,
channelSpec: PropTypes.object.isRequired,
onPropertyChange: PropTypes.func.isRequired
};
renderDayPicker(c) {
return (
<span className="mt1">
<span className="h4 text-bold mx1">on</span>
<Select
value={_.find(DAY_OF_WEEK_OPTIONS, (o) => o.value === c.schedule_details.day_of_week)}
options={DAY_OF_WEEK_OPTIONS}
optionNameFn={o => o.name}
optionValueFn={o => o.value}
onChange={(o) => this.props.onPropertyChange("schedule_details", { ...c.schedule_details, day_of_week: o }) }
/>
</span>
);
}
renderHourPicker(c) {
let hourOfDay = isNaN(c.schedule_details.hour_of_day) ? 8 : c.schedule_details.hour_of_day;
let hour = hourOfDay % 12;
let amPm = hourOfDay >= 12 ? 1 : 0;
return (
<div className="mt1">
<span className="h4 text-bold mr1">at</span>
<Select
className="mr1"
value={_.find(HOUR_OPTIONS, (o) => o.value === hour)}
options={HOUR_OPTIONS}
optionNameFn={o => o.name}
optionValueFn={o => o.value}
onChange={(o) => this.props.onPropertyChange("schedule_details", { ...c.schedule_details, hour_of_day: o + amPm * 12 }) }
/>
<Select
value={_.find(AM_PM_OPTIONS, (o) => o.value === amPm)}
options={AM_PM_OPTIONS}
optionNameFn={o => o.name}
optionValueFn={o => o.value}
onChange={(o) => this.props.onPropertyChange("schedule_details", { ...c.schedule_details, hour_of_day: hour + o * 12 }) }
/>
</div>
);
}
render() {
let { channel, channelSpec } = this.props;
return (
<div className="mt1">
<span className="h4 text-bold mr1">Sent</span>
<Select
className="h4 text-bold"
value={channel.schedule_type}
options={channelSpec.schedules}
optionNameFn={o => capitalize(o)}
optionValueFn={o => o}
onChange={(o) => this.props.onPropertyChange("schedule_type", o)}
/>
{ channel.schedule_type === "weekly" &&
this.renderDayPicker(channel)
}
{ (channel.schedule_type === "daily" || channel.schedule_type === "weekly") &&
this.renderHourPicker(channel)
}
</div>
);
}
}
......@@ -48,7 +48,7 @@ Pulse.config(['$routeProvider', function ($routeProvider) {
function($scope, $location, $route, $routeParams) {
$scope.Component = PulseEditApp;
$scope.props = {
pulseId: $routeParams.pulseId
pulseId: parseInt($routeParams.pulseId)
};
$scope.store = finalCreateStore(reducer, {});
}
......
......@@ -6,7 +6,8 @@ import {
SET_EDITING_PULSE,
UPDATE_EDITING_PULSE,
SAVE_EDITING_PULSE,
FETCH_CARDS
FETCH_CARDS,
FETCH_USERS
} from "./actions";
export const pulses = handleActions({
......@@ -33,3 +34,8 @@ export const cards = handleActions({
export const cardList = handleActions({
[FETCH_CARDS]: { next: (state, { payload }) => payload.result }
}, []);
// NOTE: duplicated from admin/people/reducers.js
export const users = handleActions({
[FETCH_USERS]: { next: (state, { payload }) => ({ ...payload.entities.user }) }
}, []);
......@@ -23,12 +23,19 @@ const editingPulseSelector = state => state.editingPulse;
const cardsSelector = state => state.cards
const cardIdListSelector = state => state.cardList
const usersSelector = state => state.users
const cardListSelector = createSelector(
[cardIdListSelector, cardsSelector],
(cardIdList, cards) => cardIdList && cardIdList.map(id => cards[id])
);
const userListSelector = createSelector(
[usersSelector],
(users) => Object.values(users)
);
export const editPulseSelectors = createSelector(
[editingPulseSelector, cardsSelector, cardListSelector],
(pulse, cards, cardList) => ({ pulse, cards, cardList })
[editingPulseSelector, cardsSelector, cardListSelector, userListSelector],
(pulse, cards, cardList, userList) => ({ pulse, cards, cardList, userList })
);
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