Skip to content
Snippets Groups Projects
Commit 6dc801d4 authored by Allen Gilliland's avatar Allen Gilliland
Browse files

Merge pull request #1544 from metabase/slack-settings

Custom Slack settings with validation
parents e5da336d 9f6d406b
No related branches found
No related tags found
No related merge requests found
......@@ -3,6 +3,7 @@ import React, { Component, PropTypes } from "react";
import SettingsHeader from "./SettingsHeader.jsx";
import SettingsSetting from "./SettingsSetting.jsx";
import SettingsEmailForm from "./SettingsEmailForm.jsx";
import SettingsSlackForm from "./SettingsSlackForm.jsx";
import _ from "underscore";
import cx from 'classnames';
......@@ -58,12 +59,23 @@ export default class SettingsEditor extends Component {
if (section.name === "Email") {
return (
<div className="MetadataTable px2 flex-full">
<div className="px2">
<SettingsEmailForm
ref="emailForm"
elements={section.settings}
updateEmailSettings={this.props.updateEmailSettings}
sendTestEmail={this.props.sendTestEmail} />
sendTestEmail={this.props.sendTestEmail}
/>
</div>
);
} else if (section.name === "Slack") {
return (
<div className="px2">
<SettingsSlackForm
ref="slackForm"
elements={section.settings}
updateSlackSettings={this.props.updateSlackSettings}
/>
</div>
);
} else {
......@@ -72,7 +84,7 @@ export default class SettingsEditor extends Component {
});
return (
<div className="MetadataTable px2 flex-full">
<div className="px2">
<ul>{settings}</ul>
</div>
);
......
import React, { Component, PropTypes } from "react";
import MetabaseUtils from "metabase/lib/utils";
import SettingsEmailFormElement from "./SettingsEmailFormElement.jsx";
import Icon from "metabase/components/Icon.jsx";
import RetinaImage from "react-retina-image";
import cx from "classnames";
import _ from "underscore";
export default class SettingsSlackForm extends Component {
constructor(props, context) {
super(props, context);
this.state = {
dirty: false,
formData: {},
submitting: "default",
valid: false,
validationErrors: {}
}
}
static propTypes = {
elements: PropTypes.object,
formErrors: PropTypes.object,
updateSlackSettings: PropTypes.func.isRequired
};
componentWillMount() {
// this gives us an opportunity to load up our formData with any existing values for elements
let formData = {};
this.props.elements.forEach(function(element) {
formData[element.key] = element.value;
});
this.setState({formData});
}
componentDidMount() {
this.validateForm();
}
componentDidUpdate() {
this.validateForm();
}
setSubmitting(submitting) {
this.setState({submitting});
}
setFormErrors(formErrors) {
this.setState({formErrors});
}
// return null if element passes validation, otherwise return an error message
validateElement([validationType, validationMessage], value, element) {
if (MetabaseUtils.isEmpty(value)) return;
switch (validationType) {
case "email":
return !MetabaseUtils.validEmail(value) ? (validationMessage || "That's not a valid email address") : null;
case "integer":
return isNaN(parseInt(value)) ? (validationMessage || "That's not a valid integer") : null;
}
}
validateForm() {
let { elements } = this.props;
let { formData } = this.state;
let valid = true,
validationErrors = {};
elements.forEach(function(element) {
// test for required elements
if (element.required && MetabaseUtils.isEmpty(formData[element.key])) {
valid = false;
}
if (element.validations) {
element.validations.forEach(function(validation) {
validationErrors[element.key] = this.validateElement(validation, formData[element.key], element);
if (validationErrors[element.key]) valid = false;
}, this);
};
}, this);
if (this.state.valid !== valid || !_.isEqual(this.state.validationErrors, validationErrors)) {
this.setState({ valid, validationErrors });
}
}
handleChangeEvent(element, value, event) {
this.setState({
dirty: true,
formData: { ...this.state.formData, [element.key]: (MetabaseUtils.isEmpty(value)) ? null : value }
});
}
handleFormErrors(error) {
// parse and format
let formErrors = {};
if (error.data && error.data.message) {
formErrors.message = error.data.message;
} else {
formErrors.message = "Looks like we ran into some problems";
}
if (error.data && error.data.errors) {
formErrors.elements = error.data.errors;
}
return formErrors;
}
updateSlackSettings(e) {
e.preventDefault();
this.setState({
formErrors: null,
submitting: "working"
});
let { formData, valid } = this.state;
if (valid) {
this.props.updateSlackSettings(formData).then(() => {
this.setState({
dirty: false,
submitting: "success"
});
// show a confirmation for 3 seconds, then return to normal
setTimeout(() => this.setState({submitting: "default"}), 3000);
}, (error) => {
this.setState({
submitting: "default",
formErrors: this.handleFormErrors(error)
});
});
}
}
render() {
let { elements } = this.props;
let { dirty, formData, formErrors, submitting, valid, validationErrors } = this.state;
let settings = elements.map((element, index) => {
// merge together data from a couple places to provide a complete view of the Element state
let errorMessage = (formErrors && formErrors.elements) ? formErrors.elements[element.key] : validationErrors[element.key],
value = formData[element.key] || element.defaultValue;
return <SettingsEmailFormElement
key={element.key}
element={_.extend(element, {value, errorMessage })}
handleChangeEvent={this.handleChangeEvent.bind(this)} />
});
let saveSettingsButtonStates = {
default: "Save changes",
working: "Saving...",
success: "Changes saved!"
};
let disabled = (!valid || submitting !== "default"),
saveButtonText = saveSettingsButtonStates[submitting];
return (
<form noValidate>
<div className="px2" style={{maxWidth: "585px"}}>
<h1>
Metabase
<RetinaImage
className="mx1"
src="/app/img/slack_emoji.png"
width={79}
forceOriginalDimensions={false /* broken in React v0.13 */}
/>
Slack
</h1>
<h3 className="text-grey-1">Answers sent right to your Slack #channels</h3>
<div className="pt3">
<a href="https://api.slack.com/web#authentication" target="_blank" className="Button Button--primary" style={{padding:0}}>
<div className="float-left py2 pl2">Get an API token from Slack</div>
<Icon className="float-right p2 text-white cursor-pointer" style={{opacity:0.6}} name="external" width={18} height={18}/>
</a>
</div>
<div className="py2">
Once you're there, click <strong>"Create token"</strong> next to the team that you want to integrate with Metabase, then copy and paste the token into the field below.
</div>
</div>
<ul>
{settings}
<li className="m2 mb4">
<button className={cx("Button mr2", {"Button--primary": !disabled}, {"Button--success-new": submitting === "success"})} disabled={disabled} onClick={this.updateSlackSettings.bind(this)}>
{saveButtonText}
</button>
{ formErrors && formErrors.message ? <span className="pl2 text-error text-bold">{formErrors.message}</span> : null}
</li>
</ul>
</form>
);
}
}
......@@ -110,8 +110,8 @@ const SECTIONS = [
{
key: "slack-token",
display_name: "Slack API Token",
description: "Slack API bearer token obtained from https://api.slack.com/web#authentication",
placeholder: "a-bunch-of-crazy-letters-and-numbers",
description: "",
placeholder: "Enter the token you recieved from Slack",
type: "string",
required: true,
autoFocus: true
......@@ -120,8 +120,8 @@ const SECTIONS = [
}
];
SettingsAdminControllers.controller('SettingsEditor', ['$scope', '$location', 'Settings', 'Email', 'AppState', 'settings',
function($scope, $location, Settings, Email, AppState, settings) {
SettingsAdminControllers.controller('SettingsEditor', ['$scope', '$location', 'Settings', 'Email', 'Slack', 'AppState', 'settings',
function($scope, $location, Settings, Email, Slack, AppState, settings) {
$scope.SettingsEditor = SettingsEditor;
if ('section' in $location.search()) {
......@@ -140,6 +140,11 @@ SettingsAdminControllers.controller('SettingsEditor', ['$scope', '$location', 'S
AppState.refreshSiteSettings();
}
$scope.updateSlackSettings = async function(settings) {
await Slack.updateSettings(settings).$promise;
AppState.refreshSiteSettings();
}
$scope.sendTestEmail = async function(settings) {
await Email.sendTest().$promise;
}
......
......@@ -51,6 +51,7 @@ export var ICON_PATHS = {
ellipsis: 'M26.1111111,19 C27.7066004,19 29,17.6568542 29,16 C29,14.3431458 27.7066004,13 26.1111111,13 C24.5156218,13 23.2222222,14.3431458 23.2222222,16 C23.2222222,17.6568542 24.5156218,19 26.1111111,19 Z M5.88888889,19 C7.48437817,19 8.77777778,17.6568542 8.77777778,16 C8.77777778,14.3431458 7.48437817,13 5.88888889,13 C4.29339961,13 3,14.3431458 3,16 C3,17.6568542 4.29339961,19 5.88888889,19 Z M16,19 C17.5954893,19 18.8888889,17.6568542 18.8888889,16 C18.8888889,14.3431458 17.5954893,13 16,13 C14.4045107,13 13.1111111,14.3431458 13.1111111,16 C13.1111111,17.6568542 14.4045107,19 16,19 Z',
expand: 'M29,13.6720571 L29,8.26132482e-16 L15.3279429,8.64083276e-16 L20.3471502,5.01920738 L15.0015892,10.3647684 L18.6368207,14 L23.9823818,8.65443894 L29,13.6720571 Z M0.00158917013,15.3279429 L0.00158917013,29 L13.6736463,29 L8.65443894,23.9807926 L14,18.6352316 L10.3647684,15 L5.01920738,20.3455611 L0.00158917013,15.3279429 Z',
explore: 'M16.4796545,16.298957 L16.4802727,23.0580389 L16.4802727,23.0580389 C17.3528782,23.2731238 18,24.0609902 18,25 C18,26.1045695 17.1045695,27 16,27 C14.8954305,27 14,26.1045695 14,25 C14,24.0751922 14.6276951,23.2969904 15.4802906,23.0681896 L15.4796772,16.3617812 L15.4796772,16.3617812 L9.42693239,19.2936488 C9.54250354,19.9090101 9.36818637,20.5691625 8.90013616,21.0538426 C8.13283771,21.8484034 6.86670062,21.8705039 6.07213982,21.1032055 C5.27757902,20.335907 5.25547851,19.06977 6.02277696,18.2752092 C6.79007541,17.4806484 8.0562125,17.4585478 8.8507733,18.2258463 C8.90464955,18.277874 8.95497425,18.3321952 9.00174214,18.3885073 L14.8957415,15.5335339 L8.95698016,12.663638 C8.54316409,13.1288103 7.91883307,13.3945629 7.25239963,13.3245179 C6.15388108,13.2090589 5.35695382,12.2249357 5.47241277,11.1264172 C5.58787172,10.0278986 6.57199493,9.23097136 7.67051349,9.34643031 C8.76903204,9.46188927 9.5659593,10.4460125 9.45050035,11.544531 C9.44231425,11.6224166 9.42976147,11.6987861 9.41311084,11.7734218 L15.4795257,14.705006 L15.4789062,7.93143834 C14.6270158,7.70216703 14,6.9243072 14,6 C14,4.8954305 14.8954305,4 16,4 C17.1045695,4 18,4.8954305 18,6 C18,6.93950562 17.3521946,7.72770818 16.4788902,7.94230133 L16.4795143,14.7663758 L22.5940736,11.8045661 C22.4397082,11.1620316 22.6068068,10.4567329 23.0998638,9.94615736 C23.8671623,9.15159656 25.1332994,9.12949606 25.9278602,9.8967945 C26.722421,10.664093 26.7445215,11.93023 25.977223,12.7247908 C25.2099246,13.5193516 23.9437875,13.5414522 23.1492267,12.7741537 C23.120046,12.7459743 23.0919072,12.717122 23.0648111,12.687645 L17.1917924,15.5324558 L23.0283963,18.3529842 C23.4420438,17.8775358 24.073269,17.604607 24.7476004,17.6754821 C25.8461189,17.7909411 26.6430462,18.7750643 26.5275872,19.8735828 C26.4121283,20.9721014 25.4280051,21.7690286 24.3294865,21.6535697 C23.230968,21.5381107 22.4340407,20.5539875 22.5494996,19.455469 C22.5569037,19.3850239 22.56788,19.315819 22.5822296,19.2480155 L16.4796545,16.298957 Z M16.0651172,6.99791382 C16.5870517,6.96436642 17,6.53040783 17,6 C17,5.44771525 16.5522847,5 16,5 C15.4477153,5 15,5.44771525 15,6 C15,6.53446591 15.4192913,6.9710011 15.9468816,6.99861337 L16.0651172,6.99791382 L16.0651172,6.99791382 Z M16,26 C16.5522847,26 17,25.5522847 17,25 C17,24.4477153 16.5522847,24 16,24 C15.4477153,24 15,24.4477153 15,25 C15,25.5522847 15.4477153,26 16,26 Z M6.56266251,20.102897 C6.80476821,20.5992873 7.40343746,20.8054256 7.89982771,20.5633199 C8.39621795,20.3212142 8.60235631,19.722545 8.36025061,19.2261547 C8.11814491,18.7297645 7.51947566,18.5236261 7.02308541,18.7657318 C6.52669517,19.0078375 6.32055681,19.6065068 6.56266251,20.102897 Z M23.6397494,11.7738453 C23.8818551,12.2702355 24.4805243,12.4763739 24.9769146,12.2342682 C25.4733048,11.9921625 25.6794432,11.3934932 25.4373375,10.897103 C25.1952318,10.4007127 24.5965625,10.1945744 24.1001723,10.4366801 C23.603782,10.6787858 23.3976437,11.277455 23.6397494,11.7738453 Z M25.4373375,20.102897 C25.6794432,19.6065068 25.4733048,19.0078375 24.9769146,18.7657318 C24.4805243,18.5236261 23.8818551,18.7297645 23.6397494,19.2261547 C23.3976437,19.722545 23.603782,20.3212142 24.1001723,20.5633199 C24.5965625,20.8054256 25.1952318,20.5992873 25.4373375,20.102897 Z M8.36025061,11.7738453 C8.60235631,11.277455 8.39621795,10.6787858 7.89982771,10.4366801 C7.40343746,10.1945744 6.80476821,10.4007127 6.56266251,10.897103 C6.32055681,11.3934932 6.52669517,11.9921625 7.02308541,12.2342682 C7.51947566,12.4763739 8.11814491,12.2702355 8.36025061,11.7738453 Z',
external: 'M13.7780693,4.44451732 L5.1588494,4.44451732 C2.32615959,4.44451732 0,6.75504816 0,9.60367661 L0,25.1192379 C0,27.9699171 2.30950226,30.2783972 5.1588494,30.2783972 L18.9527718,30.2783972 C21.7854617,30.2783972 24.1116212,27.9678664 24.1116212,25.1192379 L24.1116212,19.9448453 L20.6671039,19.9448453 L20.6671039,25.1192379 C20.6671039,26.0662085 19.882332,26.8338799 18.9527718,26.8338799 L5.1588494,26.8338799 C4.21204994,26.8338799 3.44451732,26.0677556 3.44451732,25.1192379 L3.44451732,9.60367661 C3.44451732,8.656706 4.22928927,7.88903464 5.1588494,7.88903464 L13.7780693,7.88903464 L13.7780693,4.44451732 L13.7780693,4.44451732 Z M30.9990919,14.455325 L30.9990919,1 L17.5437669,1 L22.4834088,5.93964193 L17.2225866,11.2004641 L20.8001918,14.7780693 L26.061014,9.51724709 L30.9990919,14.455325 L30.9990919,14.455325 L30.9990919,14.455325 Z',
filter: {
svg: '<g><path d="M1,12 L17,12 L17,14 L1,14 L1,12 Z M1,7 L17,7 L17,9 L1,9 L1,7 Z M1,2 L17,2 L17,4 L1,4 L1,2 Z" fill="currentcolor"></path><path d="M9,15.5 C10.3807119,15.5 11.5,14.3807119 11.5,13 C11.5,11.6192881 10.3807119,10.5 9,10.5 C7.61928813,10.5 6.5,11.6192881 6.5,13 C6.5,14.3807119 7.61928813,15.5 9,15.5 Z M13,5.5 C14.3807119,5.5 15.5,4.38071187 15.5,3 C15.5,1.61928813 14.3807119,0.5 13,0.5 C11.6192881,0.5 10.5,1.61928813 10.5,3 C10.5,4.38071187 11.6192881,5.5 13,5.5 Z M3,10.5 C4.38071187,10.5 5.5,9.38071187 5.5,8 C5.5,6.61928813 4.38071187,5.5 3,5.5 C1.61928813,5.5 0.5,6.61928813 0.5,8 C0.5,9.38071187 1.61928813,10.5 3,10.5 Z" id="Path" fill="currentcolor"></path><path d="M13,4.5 C12.1715729,4.5 11.5,3.82842712 11.5,3 C11.5,2.17157288 12.1715729,1.5 13,1.5 C13.8284271,1.5 14.5,2.17157288 14.5,3 C14.5,3.82842712 13.8284271,4.5 13,4.5 Z M9,14.5 C8.17157288,14.5 7.5,13.8284271 7.5,13 C7.5,12.1715729 8.17157288,11.5 9,11.5 C9.82842712,11.5 10.5,12.1715729 10.5,13 C10.5,13.8284271 9.82842712,14.5 9,14.5 Z M3,9.5 C2.17157288,9.5 1.5,8.82842712 1.5,8 C1.5,7.17157288 2.17157288,6.5 3,6.5 C3.82842712,6.5 4.5,7.17157288 4.5,8 C4.5,8.82842712 3.82842712,9.5 3,9.5 Z" fill="#FFFFFF"></path></g>',
attrs: { viewBox: '0 0 19 16' }
......
......@@ -385,6 +385,16 @@ CoreServices.factory('Email', ['$resource', function($resource) {
});
}]);
CoreServices.factory('Slack', ['$resource', function($resource) {
return $resource('/api/slack', {}, {
updateSettings: {
url: '/api/slack/settings',
method: 'PUT'
}
});
}]);
CoreServices.factory('ForeignKey', ['$resource', '$cookies', function($resource, $cookies) {
return $resource('/api/foreignkey/:fkID', {}, {
delete: {
......
resources/frontend_client/app/img/slack_emoji.png

3.5 KiB

resources/frontend_client/app/img/slack_emoji@2x.png

9.62 KiB

......@@ -15,6 +15,7 @@
[session :as session]
[setting :as setting]
[setup :as setup]
[slack :as slack]
[table :as table]
[tiles :as tiles]
[user :as user]
......@@ -47,6 +48,7 @@
(context "/session" [] session/routes)
(context "/setting" [] (+auth setting/routes))
(context "/setup" [] setup/routes)
(context "/slack" [] (+auth slack/routes))
(context "/table" [] (+auth table/routes))
(context "/tiles" [] (+auth tiles/routes))
(context "/user" [] (+auth user/routes))
......
(ns metabase.api.slack
"/api/slack endpoints"
(:require [clojure.tools.logging :as log]
[clojure.set :as set]
[cheshire.core :as cheshire]
[compojure.core :refer [GET PUT DELETE POST]]
[metabase.api.common :refer :all]
[metabase.config :as config]
[metabase.integrations [slack :refer [slack-api-get]]]
[metabase.models.setting :as setting]))
(defn- humanize-error-messages
"Convert raw error message responses from Slack into our normal api error response structure."
[response body]
(case (get body "error")
"invalid_auth" {:errors {:slack-token "Invalid token"}}
{:message "Sorry, something went wrong. Please try again."}))
(defendpoint PUT "/settings"
"Update multiple `Settings` values. You must be a superuser to do this."
[:as {settings :body}]
{settings [Required Dict]}
(check-superuser)
(let [slack-token (:slack-token settings)
response (if-not (config/is-test?)
;; in normal conditions, validate connection
(slack-api-get slack-token "channels.list" {:exclude_archived 1})
;; for unit testing just respond with a success message
{:status 200 :body "{\"ok\":true}"})
body (if (= 200 (:status response)) (cheshire/parse-string (:body response)))]
(if (= true (get body "ok"))
;; test was good, save our settings
(setting/set :slack-token slack-token)
;; test failed, return response message
{:status 500
:body (humanize-error-messages response body)})))
(define-routes)
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