Skip to content
Snippets Groups Projects
Unverified Commit 9b1d159f authored by Ryan Senior's avatar Ryan Senior Committed by GitHub
Browse files

Merge pull request #6556 from metabase/pulse-attachments

Added CSV and XLS flags to pulse cards to the Pulse API
parents cb750cd4 de61b731
No related branches found
No related tags found
No related merge requests found
Showing
with 371 additions and 102 deletions
......@@ -29,7 +29,10 @@ export const getDefaultAlert = (question, user) => {
};
return {
card: { id: question.id() },
card: {
id: question.id(),
include_csv: alertType === ALERT_TYPE_ROWS
},
channels: [defaultEmailChannel],
...typeDependentAlertFields
};
......
......@@ -12,7 +12,7 @@ export default class ChannelSetupModal extends Component {
user: PropTypes.object.isRequired,
entityNamePlural: PropTypes.string.isRequired,
channels: PropTypes.array,
fullPageModal: PropTypes.boolean,
fullPageModal: PropTypes.bool,
};
static defaultProps = {
......@@ -36,4 +36,3 @@ export default class ChannelSetupModal extends Component {
);
}
}
......@@ -8,7 +8,7 @@ export default class CheckBox extends Component {
static propTypes = {
checked: PropTypes.bool,
onChange: PropTypes.func,
color: PropTypes.oneOf(defaultColors),
color: PropTypes.oneOf(Object.keys(defaultColors)),
size: PropTypes.number, // TODO - this should probably be a concrete set of options
padding: PropTypes.number// TODO - the component should pad itself properly based on the size
};
......
......@@ -47,7 +47,7 @@ export default class SchedulePicker extends Component {
schedule: PropTypes.object.isRequired,
// TODO: hourly option?
// available schedules, e.g. [ "daily", "weekly", "monthly"]
scheduleOptions: PropTypes.object.isRequired,
scheduleOptions: PropTypes.array.isRequired,
// text before Daily/Weekly/Monthly... option
textBeforeInterval: PropTypes.string,
// text prepended to "12:00 PM PST, your Metabase timezone"
......
......@@ -27,6 +27,10 @@ export var ICON_PATHS = {
attrs: { fillRule: "evenodd" }
},
area: 'M31.154 28.846l.852.004V8.64l-1.15 2.138-6.818 6.37c-.13.122-9.148 1.622-9.148 1.622l-.545.096-.383.4-7.93 8.31-1.016 1.146 2.227.017 23.91.107L7.25 28.74l7.93-8.31 9.615-1.684 7.211-6.737v15.984a.855.855 0 0 1-.852.854zM0 28.74l11.79-13.362 11.788-3.369 8.077-8.07c.194-.193.351-.128.351.15V28.85L0 28.74z',
attachment: {
path: "M22.162 8.704c.029 8.782-.038 14.123-.194 15.926-.184 2.114-2.922 4.322-5.9 4.322-3.06 0-5.542-1.98-5.836-4.376-.294-2.392-.195-14.266.01-18.699.077-1.661 1.422-2.83 3.548-2.83 2.067 0 3.488 1.335 3.594 3.164.06 1.052.074 3.49.053 7.107-.006.928-.013 1.891-.023 3.072l-.023 2.527c-.006.824-.01 1.358-.01 1.718 0 1.547-.39 2.011-1.475 2.011-.804 0-1.202-.522-1.202-1.38V8.699a1.524 1.524 0 0 0-3.048 0v12.567c0 2.389 1.554 4.428 4.25 4.428 2.897 0 4.523-1.934 4.523-5.06 0-.348.003-.875.01-1.691l.022-2.526c.01-1.184.018-2.15.024-3.082.021-3.697.008-6.155-.058-7.3C20.227 2.592 17.469 0 13.79 0c-3.695 0-6.438 2.382-6.593 5.737-.213 4.613-.312 16.585.01 19.21C7.697 28.94 11.53 32 16.067 32c4.482 0 8.61-3.327 8.937-7.106.168-1.935.235-7.302.206-16.2a1.524 1.524 0 0 0-3.048.01z",
attrs: { fillRule: 'nonzero'}
},
backArrow: 'M11.7416687,19.0096 L18.8461178,26.4181004 L14.2696969,30.568 L0.38960831,16.093881 L0,15.6875985 L0.49145276,15.241949 L14.6347557,1 L19.136,5.22693467 L11.3214393,13.096 L32,13.096 L32,19.0096 L11.7416687,19.0096 Z',
bar: 'M2 23.467h6.4V32H2v-8.533zm10.667-12.8h6.4V32h-6.4V10.667zM23.333 0h6.4v32h-6.4V0z',
beaker: 'M4.31736354,31.1631075 C3.93810558,30.6054137 3.89343681,29.6635358 4.20559962,29.0817181 L11.806982,14.9140486 L11.8069821,10.5816524 L10.7015144,10.4653256 C10.0309495,10.394763 9.48734928,9.78799739 9.48734928,9.12166999 L9.48734928,7.34972895 C9.48734928,6.67821106 10.0368737,6.13383825 10.7172248,6.13383825 L21.8462005,6.13383825 C22.525442,6.13383825 23.0760761,6.68340155 23.0760761,7.34972895 L23.0760761,9.12166999 C23.0760761,9.79318788 22.5250158,10.3375607 21.856025,10.3375607 L20.9787023,10.3375607 L20.9787024,14.9281806 L28.77277,29.0827118 C29.0983515,29.6739888 29.0709073,30.6193105 28.7174156,31.1846409 L28.852457,30.9686726 C28.4963041,31.538259 27.6541076,32 26.9865771,32 L6.10749779,32 C5.43315365,32 4.58248747,31.5529687 4.19978245,30.9902061 L4.31736354,31.1631075 Z M15.5771418,17.6040443 C16.5170398,17.6040443 17.2789777,16.8377777 17.2789777,15.89254 C17.2789777,14.9473023 16.5170398,14.1810358 15.5771418,14.1810358 C14.6372438,14.1810358 13.8753059,14.9473023 13.8753059,15.89254 C13.8753059,16.8377777 14.6372438,17.6040443 15.5771418,17.6040443 Z M16.5496195,12.8974079 C17.8587633,12.8974079 18.9200339,11.830108 18.9200339,10.5135268 C18.9200339,9.1969457 17.8587633,8.1296458 16.5496195,8.1296458 C15.2404758,8.1296458 14.1792052,9.1969457 14.1792052,10.5135268 C14.1792052,11.830108 15.2404758,12.8974079 16.5496195,12.8974079 Z M5.71098553,30.2209651 L10.9595331,20.5151267 C10.9595331,20.5151267 12.6834557,21.2672852 14.3734184,21.2672852 C16.0633811,21.2672852 16.8198616,19.2872624 17.588452,18.6901539 C18.3570425,18.0930453 19.9467191,17.1113296 19.9467191,17.1113296 L27.0506095,30.1110325 L5.71098553,30.2209651 Z M13.6608671,4.37817079 C14.4114211,4.37817079 15.0198654,3.78121712 15.0198654,3.04483745 C15.0198654,2.30845779 14.4114211,1.71150412 13.6608671,1.71150412 C12.9103132,1.71150412 12.3018689,2.30845779 12.3018689,3.04483745 C12.3018689,3.78121712 12.9103132,4.37817079 13.6608671,4.37817079 Z M17.9214578,2.45333328 C18.6119674,2.45333328 19.1717361,1.90413592 19.1717361,1.22666664 C19.1717361,0.549197362 18.6119674,0 17.9214578,0 C17.2309481,0 16.6711794,0.549197362 16.6711794,1.22666664 C16.6711794,1.90413592 17.2309481,2.45333328 17.9214578,2.45333328 Z',
......
......@@ -35,9 +35,41 @@ export function pulseIsValid(pulse, channelSpecs) {
) || false;
}
export function emailIsEnabled(pulse) {
return pulse.channels.filter(c => c.channel_type === "email" && c.enabled).length > 0;
}
export function cleanPulse(pulse, channelSpecs) {
return {
...pulse,
channels: pulse.channels.filter((c) => channelIsValid(c, channelSpecs && channelSpecs[c.channel_type]))
};
}
export function getDefaultChannel(channelSpecs) {
// email is the first choice
if (channelSpecs.email.configured) {
return channelSpecs.email;
}
// otherwise just pick the first configured
for (const channelSpec of Object.values(channelSpecs)) {
if (channelSpec.configured) {
return channelSpec;
}
}
}
export function createChannel(channelSpec) {
const details = {};
return {
channel_type: channelSpec.type,
enabled: true,
recipients: [],
details: details,
schedule_type: channelSpec.schedules[0],
schedule_day: "mon",
schedule_hour: 8,
schedule_frame: "first"
};
}
......@@ -4,6 +4,9 @@ import { createThunkAction } from "metabase/lib/redux";
import { normalize, schema } from "normalizr";
import { PulseApi, CardApi, UserApi } from "metabase/services";
import { formInputSelector } from "./selectors";
import { getDefaultChannel, createChannel } from "metabase/lib/pulse";
const card = new schema.Entity('card');
const pulse = new schema.Entity('pulse');
......@@ -37,10 +40,16 @@ export const setEditingPulse = createThunkAction(SET_EDITING_PULSE, function(id)
} catch (e) {
}
}
// HACK: need a way to wait for form_input to finish loading
const channels = formInputSelector(getState()).channels ||
(await PulseApi.form_input()).channels;
const defaultChannelSpec = getDefaultChannel(channels);
return {
name: null,
cards: [],
channels: [],
channels: defaultChannelSpec ?
[createChannel(defaultChannelSpec)] :
[],
skip_if_empty: false,
}
};
......@@ -93,7 +102,7 @@ export const fetchUsers = createThunkAction(FETCH_USERS, function() {
};
});
export const fetchPulseFormInput = createThunkAction(FETCH_PULSE_FORM_INPUT, function(id) {
export const fetchPulseFormInput = createThunkAction(FETCH_PULSE_FORM_INPUT, function() {
return async function(dispatch, getState) {
return await PulseApi.form_input();
};
......
......@@ -21,7 +21,8 @@ export default class CardPicker extends Component {
static propTypes = {
cardList: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired
onChange: PropTypes.func.isRequired,
attachmentsEnabled: PropTypes.bool,
};
componentWillUnmount() {
......@@ -56,13 +57,14 @@ export default class CardPicker extends Component {
}
renderItem(card) {
const { attachmentsEnabled } = this.props;
let error;
try {
if (Query.isBareRows(card.dataset_query.query)) {
if (!attachmentsEnabled && Query.isBareRows(card.dataset_query.query)) {
error = t`Raw data cannot be included in pulses`;
}
} catch (e) {}
if (card.display === "pin_map" || card.display === "state" || card.display === "country") {
if (!attachmentsEnabled && (card.display === "pin_map" || card.display === "state" || card.display === "country")) {
error = t`Maps cannot be included in pulses`;
}
......@@ -160,7 +162,7 @@ export default class CardPicker extends Component {
: collections ?
<CollectionList>
{collections.map(collection =>
<CollectionListItem collection={collection} onClick={(e) => {
<CollectionListItem key={collection.id} collection={collection} onClick={(e) => {
this.setState({ collectionId: collection.id, isClicking: true });
}}/>
)}
......
......@@ -5,6 +5,10 @@ import PropTypes from "prop-types";
import Icon from "metabase/components/Icon.jsx";
import LoadingSpinner from "metabase/components/LoadingSpinner.jsx";
import Tooltip from "metabase/components/Tooltip.jsx";
import { t } from "c-3po";
import cx from "classnames";
export default class PulseCardPreview extends Component {
constructor(props, context) {
......@@ -14,22 +18,83 @@ export default class PulseCardPreview extends Component {
static propTypes = {
card: PropTypes.object.isRequired,
cardPreview: PropTypes.object,
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
fetchPulseCardPreview: PropTypes.func.isRequired,
attachmentsEnabled: PropTypes.bool,
};
componentWillMount() {
this.props.fetchPulseCardPreview(this.props.card.id);
}
componentWillReceiveProps(nextProps) {
// if we can't render this card as a pulse, set include_csv = true
const unrenderablePulseCard = nextProps.cardPreview && nextProps.cardPreview.pulse_card_type == null;
const hasAttachment = nextProps.card.include_csv || nextProps.card.include_xls;
if (unrenderablePulseCard && !hasAttachment) {
nextProps.onChange({ ...nextProps.card, include_csv: true })
}
}
hasAttachment() {
const { card } = this.props;
return card.include_csv || card.include_xls;
}
toggleAttachment = () => {
const { card, onChange } = this.props;
if (this.hasAttachment()) {
onChange({ ...card, include_csv: false, include_xls: false })
} else {
onChange({ ...card, include_csv: true })
}
}
render() {
let { cardPreview } = this.props;
let { cardPreview, attachmentsEnabled } = this.props;
const hasAttachment = this.hasAttachment();
const isAttachmentOnly = attachmentsEnabled && hasAttachment && cardPreview && cardPreview.pulse_card_type == null;
return (
<div className="flex relative flex-full">
<a className="text-grey-2 absolute" style={{ top: "15px", right: "15px" }} onClick={this.props.onRemove}>
<Icon name="close" size={16} />
</a>
<div className="bordered rounded flex-full scroll-x" style={{ display: !cardPreview && "none" }} dangerouslySetInnerHTML={{__html: cardPreview && cardPreview.pulse_card_html}} />
<div className="absolute top right p2 text-grey-2">
{ attachmentsEnabled && !isAttachmentOnly &&
<Tooltip tooltip={hasAttachment ? t`Remove attachment` : t`Attach file with results`}>
<Icon
name="attachment" size={18}
className={cx("cursor-pointer py1 pr1 text-brand-hover", { "text-brand": this.hasAttachment() })}
onClick={this.toggleAttachment}
/>
</Tooltip>
}
<Icon
name="close" size={18}
className="cursor-pointer py1 pr1 text-brand-hover"
onClick={this.props.onRemove}
/>
</div>
<div
className="bordered rounded flex-full scroll-x"
style={{ display: !cardPreview && "none" }}
>
{/* Override backend rendering if pulse_card_type == null */}
{ cardPreview && cardPreview.pulse_card_type == null ?
<RenderedPulseCardPreview href={cardPreview.pulse_card_url}>
<RenderedPulseCardPreviewHeader>
{cardPreview.pulse_card_name}
</RenderedPulseCardPreviewHeader>
<RenderedPulseCardPreviewMessage>
{ isAttachmentOnly ?
t`This question will be added as a file attachment`
:
t`This question won't be included in your Pulse`
}
</RenderedPulseCardPreviewMessage>
</RenderedPulseCardPreview>
:
<div dangerouslySetInnerHTML={{__html: cardPreview && cardPreview.pulse_card_html}} />
}
</div>
{ !cardPreview &&
<div className="flex-full flex align-center layout-centered pt1">
<LoadingSpinner className="inline-block" />
......@@ -39,3 +104,58 @@ export default class PulseCardPreview extends Component {
);
}
}
// implements the same layout as in metabase/pulse/render.clj
const RenderedPulseCardPreview = ({ href, children }) =>
<a
href={href}
style={{
fontFamily: 'Lato, "Helvetica Neue", Helvetica, Arial, sans-serif',
margin: 16,
marginBottom: 16,
display: "block",
textDecoration: "none"
}}
target="_blank"
>
{children}
</a>
RenderedPulseCardPreview.propTypes = {
href: PropTypes.string,
children: PropTypes.node
}
// implements the same layout as in metabase/pulse/render.clj
const RenderedPulseCardPreviewHeader = ({ children }) =>
<table style={{ marginBottom: 8, width: "100%" }}>
<tbody>
<tr>
<td>
<span style={{
fontFamily: 'Lato, "Helvetica Neue", Helvetica, Arial, sans-serif',
fontSize: 16,
fontWeight: 700,
color: "rgb(57,67,64)",
textDecoration: "none"
}}>
{children}
</span>
</td>
<td style={{ textAlign: "right" }}></td>
</tr>
</tbody>
</table>
RenderedPulseCardPreviewHeader.propTypes = {
children: PropTypes.node
}
const RenderedPulseCardPreviewMessage = ({ children }) =>
<div className="text-grey-4">
{children}
</div>
RenderedPulseCardPreviewMessage.propTypes = {
children: PropTypes.node
}
......@@ -17,7 +17,7 @@ import ModalContent from "metabase/components/ModalContent.jsx";
import DeleteModalWithConfirm from "metabase/components/DeleteModalWithConfirm.jsx";
import { pulseIsValid, cleanPulse } from "metabase/lib/pulse";
import { pulseIsValid, cleanPulse, emailIsEnabled } from "metabase/lib/pulse";
import _ from "underscore";
import cx from "classnames";
......@@ -75,25 +75,21 @@ export default class PulseEdit extends Component {
this.props.updateEditingPulse(pulse);
}
isValid() {
let { pulse } = this.props;
return pulse.name && pulse.cards.length && pulse.channels.length > 0 && pulse.channels.filter((c) => this.channelIsValid(c)).length > 0;
}
getConfirmItems() {
return this.props.pulse.channels.map(c =>
return this.props.pulse.channels.map((c, index) =>
c.channel_type === "email" ?
<span>{jt`This pulse will no longer be emailed to ${<strong>{c.recipients.length} {inflect("address", c.recipients.length)}</strong>} ${<strong>{c.schedule_type}</strong>}`}.</span>
<span key={index}>{jt`This pulse will no longer be emailed to ${<strong>{c.recipients.length} {inflect("address", c.recipients.length)}</strong>} ${<strong>{c.schedule_type}</strong>}`}.</span>
: c.channel_type === "slack" ?
<span>{jt`Slack channel ${<strong>{c.details && c.details.channel}</strong>} will no longer get this pulse ${<strong>{c.schedule_type}</strong>}`}.</span>
<span key={index}>{jt`Slack channel ${<strong>{c.details && c.details.channel}</strong>} will no longer get this pulse ${<strong>{c.schedule_type}</strong>}`}.</span>
:
<span>{jt`Channel ${<strong>{c.channel_type}</strong>} will no longer receive this pulse ${<strong>{c.schedule_type}</strong>}`}.</span>
<span key={index}>{jt`Channel ${<strong>{c.channel_type}</strong>} will no longer receive this pulse ${<strong>{c.schedule_type}</strong>}`}.</span>
);
}
render() {
let { pulse, formInput } = this.props;
let isValid = pulseIsValid(pulse, formInput.channels);
const { pulse, formInput } = this.props;
const isValid = pulseIsValid(pulse, formInput.channels);
const attachmentsEnabled = emailIsEnabled(pulse);
return (
<div className="PulseEdit">
<div className="PulseEdit-header flex align-center border-bottom py3">
......@@ -117,7 +113,7 @@ export default class PulseEdit extends Component {
</div>
<div className="PulseEdit-content pt2 pb4">
<PulseEditName {...this.props} setPulse={this.setPulse} />
<PulseEditCards {...this.props} setPulse={this.setPulse} />
<PulseEditCards {...this.props} setPulse={this.setPulse} attachmentsEnabled={attachmentsEnabled} />
<div className="py1 mb4">
<h2 className="mb3">Where should this data go?</h2>
<PulseEditChannels {...this.props} setPulse={this.setPulse} pulseIsValid={isValid} />
......
......@@ -2,6 +2,7 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { t } from 'c-3po';
import cx from "classnames";
import CardPicker from "./CardPicker.jsx";
import PulseCardPreview from "./PulseCardPreview.jsx";
......@@ -24,16 +25,21 @@ export default class PulseEditCards extends Component {
cards: PropTypes.object.isRequired,
cardList: PropTypes.array.isRequired,
fetchPulseCardPreview: PropTypes.func.isRequired,
setPulse: PropTypes.func.isRequired
setPulse: PropTypes.func.isRequired,
attachmentsEnabled: PropTypes.bool,
};
static defaultProps = {};
setCard(index, cardId) {
setCard(index, card) {
let { pulse } = this.props;
this.props.setPulse({
...pulse,
cards: [...pulse.cards.slice(0, index), { id: cardId }, ...pulse.cards.slice(index + 1)]
cards: [...pulse.cards.slice(0, index), card, ...pulse.cards.slice(index + 1)]
});
}
addCard(index, cardId) {
this.setCard(index, { id: cardId })
MetabaseAnalytics.trackEvent((this.props.pulseId) ? "PulseEdit" : "PulseCreate", "AddCard", index);
}
......@@ -48,41 +54,52 @@ export default class PulseEditCards extends Component {
MetabaseAnalytics.trackEvent((this.props.pulseId) ? "PulseEdit" : "PulseCreate", "RemoveCard", index);
}
getWarnings(cardPreview, showSoftLimitWarning) {
let warnings = [];
getNotices(card, cardPreview, index) {
const showSoftLimitWarning = index === SOFT_LIMIT;
let notices = [];
const hasAttachment = this.props.attachmentsEnabled && card && (card.include_csv || card.include_xls);
if (hasAttachment) {
notices.push({
head: t`Attachment`,
body: <AttachmentWidget card={card} onChange={(card) => this.setCard(index, card)} />
});
}
if (cardPreview) {
if (cardPreview.pulse_card_type === "bar" && cardPreview.row_count > 10) {
warnings.push({
head: t`Heads up`,
body: t`This is a large table and we'll have to crop it to use it in a pulse. The max size that can be displayed is 2 columns and 10 rows.`
});
}
if (cardPreview.pulse_card_type == null) {
warnings.push({
if (cardPreview.pulse_card_type == null && !hasAttachment) {
notices.push({
type: "warning",
head: t`Heads up`,
body: t`We are unable to display this card in a pulse`
body: t`Raw data questions can only be included as email attachments`
});
}
}
if (showSoftLimitWarning) {
warnings.push({
notices.push({
type: "warning",
head: t`Looks like this pulse is getting big`,
body: t`We recommend keeping pulses small and focused to help keep them digestable and useful to the whole team.`
});
}
return warnings;
return notices;
}
renderCardWarnings(card, index) {
renderCardNotices(card, index) {
let cardPreview = card && this.props.cardPreviews[card.id];
let warnings = this.getWarnings(cardPreview, index === SOFT_LIMIT);
if (warnings.length > 0) {
let notices = this.getNotices(card, cardPreview, index);
if (notices.length > 0) {
return (
<div className="absolute" style={{ width: 400, marginLeft: 420 }}>
{warnings.map(warning =>
<div className="text-gold border-gold border-left mt1 mb2 ml3 pl3" style={{ borderWidth: 3 }}>
<h3 className="mb1">{warning.head}</h3>
<div className="h4">{warning.body}</div>
{notices.map((notice, index) =>
<div
key={index}
className={cx("border-left mt1 mb2 ml3 pl3", {
"text-gold border-gold": notice.type === "warning",
"border-brand": notice.type !== "warning"
})}
style={{ borderWidth: 3 }}
>
<h3 className="mb1">{notice.head}</h3>
<div className="h4">{notice.body}</div>
</div>
)}
</div>
......@@ -113,17 +130,20 @@ export default class PulseEditCards extends Component {
<PulseCardPreview
card={card}
cardPreview={cardPreviews[card.id]}
onChange={this.setCard.bind(this, index)}
onRemove={this.removeCard.bind(this, index)}
fetchPulseCardPreview={this.props.fetchPulseCardPreview}
attachmentsEnabled={this.props.attachmentsEnabled}
/>
:
<CardPicker
cardList={cardList}
onChange={this.setCard.bind(this, index)}
onChange={this.addCard.bind(this, index)}
attachmentsEnabled={this.props.attachmentsEnabled}
/>
}
</div>
{this.renderCardWarnings(card, index)}
{this.renderCardNotices(card, index)}
</div>
</li>
)}
......@@ -132,3 +152,29 @@ export default class PulseEditCards extends Component {
);
}
}
const ATTACHMENT_TYPES = ["csv", "xls"];
const AttachmentWidget = ({ card, onChange }) =>
<div>
{ ATTACHMENT_TYPES.map(type =>
<span
key={type}
className={cx("text-brand-hover cursor-pointer mr1", { "text-brand": card["include_"+type] })}
onClick={() => {
const newCard = { ...card }
for (const attachmentType of ATTACHMENT_TYPES) {
newCard["include_" + attachmentType] = type === attachmentType;
}
onChange(newCard)
}}
>
{"." + type}
</span>
)}
</div>
AttachmentWidget.propTypes = {
card: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired
}
......@@ -16,7 +16,7 @@ import ChannelSetupMessage from "metabase/components/ChannelSetupMessage";
import MetabaseAnalytics from "metabase/lib/analytics";
import { channelIsValid } from "metabase/lib/pulse";
import { channelIsValid, createChannel } from "metabase/lib/pulse";
import cx from "classnames";
......@@ -45,7 +45,7 @@ export default class PulseEditChannels extends Component {
userList: PropTypes.array.isRequired,
setPulse: PropTypes.func.isRequired,
testPulse: PropTypes.func,
cardPreviews: PropTypes.array,
cardPreviews: PropTypes.object,
hideSchedulePicker: PropTypes.bool,
emailRecipientText: PropTypes.string
};
......@@ -59,27 +59,7 @@ export default class PulseEditChannels 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,
enabled: true,
recipients: [],
details: details,
schedule_type: channelSpec.schedules[0],
schedule_day: "mon",
schedule_hour: 8,
schedule_frame: "first"
};
let channel = createChannel(channelSpec);
this.props.setPulse({ ...pulse, channels: pulse.channels.concat(channel) });
......@@ -184,6 +164,7 @@ export default class PulseEditChannels extends Component {
<div className="h4 text-bold mb1">{ this.props.emailRecipientText || "To:" }</div>
<RecipientPicker
isNewPulse={this.props.pulseId === undefined}
autoFocus={!!this.props.pulse.name}
recipients={channel.recipients}
recipientTypes={channelSpec.recipients}
users={this.props.userList}
......
......@@ -35,7 +35,7 @@ export default class RecipientPicker extends Component {
inputValue: "",
filteredUsers: [],
selectedUserID: null,
focused: props.recipients.length === 0
focused: props.autoFocus && props.recipients.length === 0
};
}
......@@ -47,10 +47,12 @@ export default class RecipientPicker extends Component {
users: PropTypes.array,
isNewPulse: PropTypes.bool.isRequired,
onRecipientsChange: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
};
static defaultProps = {
recipientTypes: ["user", "email"]
recipientTypes: ["user", "email"],
autoFocus: true
};
setInputValue(inputValue) {
......
resources/frontend_client/app/assets/img/attachment.png

1.6 KiB

resources/frontend_client/app/assets/img/attachment@2x.png

3.6 KiB

......@@ -3998,3 +3998,25 @@ databaseChangeLog:
tableName: report_dashboardcard
columnName: card_id
columnDataType: int
- changeSet:
id: 72
author: senior
comment: 'Added 0.28.0'
changes:
- addColumn:
tableName: pulse_card
columns:
- column:
name: include_csv
type: boolean
defaultValueBoolean: false
remarks: 'True if a CSV of the data should be included for this pulse card'
constraints:
nullable: false
- column:
name: include_xls
type: boolean
defaultValueBoolean: false
remarks: 'True if a XLS of the data should be included for this pulse card'
constraints:
nullable: false
......@@ -119,7 +119,7 @@
(let [new-alert (api/check-500
(-> req
only-alert-keys
(pulse/create-alert! api/*current-user-id* (u/get-id card) channels)))]
(pulse/create-alert! api/*current-user-id* (pulse/create-card-ref card) channels)))]
(notify-new-alert-created! new-alert)
......@@ -153,7 +153,7 @@
_ (check-alert-update-permissions old-alert)
updated-alert (-> req
only-alert-keys
(assoc :id id :card (u/get-id card) :channels channels)
(assoc :id id :card (pulse/create-card-ref card) :channels channels)
pulse/update-alert!)]
;; Only admins can update recipients
......
......@@ -18,6 +18,7 @@
[pulse-channel :refer [channel-types]]]
[metabase.pulse.render :as render]
[metabase.util.schema :as su]
[metabase.util.urls :as urls]
[schema.core :as s]
[toucan.db :as db])
(:import java.io.ByteArrayInputStream
......@@ -49,7 +50,7 @@
channels (su/non-empty [su/Map])
skip_if_empty s/Bool}
(check-card-read-permissions cards)
(api/check-500 (pulse/create-pulse! name api/*current-user-id* (map u/get-id cards) channels skip_if_empty)))
(api/check-500 (pulse/create-pulse! name api/*current-user-id* (map pulse/create-card-ref cards) channels skip_if_empty)))
(api/defendpoint GET "/:id"
......@@ -70,7 +71,7 @@
(check-card-read-permissions cards)
(pulse/update-pulse! {:id id
:name name
:cards (map u/get-id cards)
:cards (map pulse/create-card-ref cards)
:channels channels
:skip-if-empty? skip_if_empty})
(pulse/retrieve-pulse id))
......@@ -130,6 +131,8 @@
{:id id
:pulse_card_type card-type
:pulse_card_html card-html
:pulse_card_name (:name card)
:pulse_card_url (urls/card-url (:id card))
:row_count (:row_count result)}))
(api/defendpoint GET "/preview_card_png/:id"
......
......@@ -12,6 +12,7 @@
[util :as u]]
[metabase.pulse.render :as render]
[metabase.util
[export :as export]
[quotation :as quotation]
[urls :as url]]
[stencil
......@@ -187,24 +188,57 @@
(defn- pulse-context [pulse]
(merge {:emailType "pulse"
:pulseName (:name pulse)
:sectionStyle render/section-style
:sectionStyle (render/style render/section-style)
:colorGrey4 render/color-gray-4
:logoFooter true}
(random-quote-context)))
(defn- create-temp-file
[suffix]
(doto (java.io.File/createTempFile "metabase_attachment" suffix)
.deleteOnExit))
(defn- create-result-attachment-map [export-type card-name ^File attachment-file]
(let [{:keys [content-type ext]} (get export/export-formats export-type)]
{:type :attachment
:content-type content-type
:file-name (format "%s.%s" card-name ext)
:content (-> attachment-file .toURI .toURL)
:description (format "Full results for '%s'" card-name)}))
(defn- result-attachments [results]
(remove nil?
(apply concat
(for [{{card-name :name, csv? :include_csv, xls? :include_xls} :card :as result} results
:when (and (or csv? xls?)
(seq (get-in result [:result :data :rows])))]
[(when-let [temp-file (and csv? (create-temp-file "csv"))]
(export/export-to-csv-writer temp-file result)
(create-result-attachment-map "csv" card-name temp-file))
(when-let [temp-file (and xls? (create-temp-file "xlsx"))]
(export/export-to-xlsx-file temp-file result)
(create-result-attachment-map "xlsx" card-name temp-file))]))))
(defn- render-message-body [message-template message-context timezone results]
(let [rendered-cards (binding [render/*include-title* true]
;; doall to ensure we haven't exited the binding before the valures are created
(doall (map #(render/render-pulse-section timezone %) results)))
message-body (assoc message-context :pulse (html (vec (cons :div (map :content rendered-cards)))))
attachments (apply merge (map :attachments rendered-cards))]
(vec (cons {:type "text/html; charset=utf-8" :content (stencil/render-file message-template message-body)}
(map make-message-attachment attachments)))))
(vec (concat [{:type "text/html; charset=utf-8" :content (stencil/render-file message-template message-body)}]
(map make-message-attachment attachments)
(result-attachments results)))))
(defn- assoc-attachment-booleans [pulse results]
(for [{{result-card-id :id} :card :as result} results
:let [pulse-card (m/find-first #(= (:id %) result-card-id) (:cards pulse))]]
(update result :card merge (select-keys pulse-card [:include_csv :include_xls]))))
(defn render-pulse-email
"Take a pulse object and list of results, returns an array of attachment objects for an email"
[timezone pulse results]
(render-message-body "metabase/email/pulse" (pulse-context pulse) timezone results))
(render-message-body "metabase/email/pulse" (pulse-context pulse) timezone (assoc-attachment-booleans pulse results)))
(defn pulse->alert-condition-kwd
"Given an `ALERT` return a keyword representing what kind of goal needs to be met."
......@@ -248,7 +282,8 @@
(let [message-ctx (default-alert-context alert (alert-results-condition-text goal-value))]
(render-message-body "metabase/email/alert"
(assoc message-ctx :firstRunOnly? alert_first_only)
timezone results)))
timezone
(assoc-attachment-booleans alert results))))
(def ^:private alert-condition-text
{:meets "when this question meets its goal"
......
......@@ -78,12 +78,16 @@
(defn ^:hydrate cards
"Return the `Cards` associated with this PULSE."
[{:keys [id]}]
(db/select [Card :id :name :description :display]
:archived false
(mdb/join [Card :id] [PulseCard :card_id])
(db/qualify PulseCard :pulse_id) id
{:order-by [[(db/qualify PulseCard :position) :asc]]}))
(map #(models/do-post-select Card %)
(db/query
{:select [:c.id :c.name :c.description :c.display :pc.include_csv :pc.include_xls]
:from [[Pulse :p]]
:join [[PulseCard :pc] [:= :p.id :pc.pulse_id]
[Card :c] [:= :c.id :pc.card_id]]
:where [:and
[:= :p.id id]
[:= :c.archived false]]
:order-by [[:pc.position :asc]]})))
;;; ------------------------------------------------------------ Pulse Fetching Helper Fns ------------------------------------------------------------
......@@ -179,6 +183,13 @@
[:not= :p.alert_condition nil]
[:in :pc.card_id card-ids]]}))))
(defn create-card-ref
"Create a card reference from a card or id"
[card]
{:id (u/get-id card)
:include_csv (get card :include_csv false)
:include_xls (get card :include_xls false)})
;;; ------------------------------------------------------------ Other Persistence Functions ------------------------------------------------------------
(defn update-pulse-cards!
......@@ -188,16 +199,20 @@
* If an ID in CARD-IDS has no corresponding existing `PulseCard` object, one will be created.
* If an existing `PulseCard` has no corresponding ID in CARD-IDs, it will be deleted.
* All cards will be updated with a `position` according to their place in the collection of CARD-IDS"
{:arglists '([pulse card-ids])}
[{:keys [id]} card-ids]
{:arglists '([pulse card-refs])}
[{:keys [id]} card-refs]
{:pre [(integer? id)
(sequential? card-ids)
(every? integer? card-ids)]}
(sequential? card-refs)
(every? map? card-refs)]}
;; first off, just delete any cards associated with this pulse (we add them again below)
(db/delete! PulseCard :pulse_id id)
;; now just insert all of the cards that were given to us
(when (seq card-ids)
(let [cards (map-indexed (fn [i card-id] {:pulse_id id, :card_id card-id, :position i}) card-ids)]
(when (seq card-refs)
(let [cards (map-indexed (fn [i {card-id :id :keys [include_csv include_xls]}]
{:pulse_id id, :card_id card-id,
:position i :include_csv include_csv,
:include_xls include_xls})
card-refs)]
(db/insert-many! PulseCard cards))))
......@@ -263,7 +278,7 @@
(integer? creator-id)
(sequential? card-ids)
(seq card-ids)
(every? integer? card-ids)
(every? map? card-ids)
(coll? channels)
(every? map? channels)]}
(let [id (create-notification {:creator_id creator-id
......@@ -305,7 +320,7 @@
(string? name)
(sequential? cards)
(> (count cards) 0)
(every? integer? cards)
(every? map? cards)
(coll? channels)
(every? map? channels)]}
(update-notification! pulse)
......
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