diff --git a/frontend/src/metabase/admin/settings/components/SettingsEditor.jsx b/frontend/src/metabase/admin/settings/components/SettingsEditor.jsx index 94cbe970fabe9b2960e6dbe4840235b78078f22b..63e9d64f7eb47222339e28ca6e7e15dc1334683d 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsEditor.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsEditor.jsx @@ -7,6 +7,7 @@ import SettingsHeader from "./SettingsHeader.jsx"; import SettingsSetting from "./SettingsSetting.jsx"; import SettingsEmailForm from "./SettingsEmailForm.jsx"; import SettingsSlackForm from "./SettingsSlackForm.jsx"; +import SettingsSetupList from "./SettingsSetupList.jsx"; import SettingsUpdatesForm from "./SettingsUpdatesForm.jsx"; import _ from "underscore"; @@ -76,6 +77,13 @@ export default class SettingsEditor extends Component { /> </div> ); + } else if (section.name === "Setup") { + return ( + <div className="px2"> + <SettingsSetupList + ref="settingsForm" /> + </div> + ); } else if (section.name === "Slack") { return ( <div className="px2"> diff --git a/frontend/src/metabase/admin/settings/components/SettingsSetupList.jsx b/frontend/src/metabase/admin/settings/components/SettingsSetupList.jsx new file mode 100644 index 0000000000000000000000000000000000000000..084aad64bec3d50bac8e5da43b302003e3522b8f --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/SettingsSetupList.jsx @@ -0,0 +1,100 @@ +import React, { Component, PropTypes } from "react"; +import Icon from "metabase/components/Icon.jsx"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; + +const TaskList = ({tasks}) => + <ol> + { tasks.map((task, index) => <li className="mb2" key={index}><Task {...task} /></li>)} + </ol> + +const TaskSectionHeader = ({name}) => + <h4 className="text-grey-4 text-bold text-uppercase pb2">{name}</h4> + +const TaskSection = ({name, tasks}) => + <div className="mb4"> + <TaskSectionHeader name={name} /> + <TaskList tasks={tasks} /> + </div> + +const TaskTitle = ({title, titleClassName}) => + <h3 className={titleClassName}>{title}</h3> + +const TaskDescription = ({description}) => <p className="m0 mt1">{description}</p> + +const CompletionBadge = ({completed}) => + <div className="mr2 flex align-center justify-center flex-no-shrink" style={{ + borderWidth: 1, + borderStyle: 'solid', + borderColor: completed ? '#9CC177' : '#DCE9EA', + backgroundColor: completed ? '#9CC177' : '#fff', + width: 32, + height: 32, + borderRadius: 99 + }}> + { completed && <Icon name="check" color={'#fff'} />} + </div> + + +export const Task = ({title, description, completed, link}) => + <a className="bordered border-brand-hover rounded transition-border flex align-center p2 no-decoration" href={link}> + <CompletionBadge completed={completed} /> + <div> + <TaskTitle title={title} titleClassName={ + completed ? 'text-success': 'text-brand' + } /> + { !completed ? <TaskDescription description={description} /> : null } + </div> + </a> + +export default class SettingsSetupList extends Component { + constructor(props, context) { + super(props, context); + this.state = { + tasks: null, + error: null + }; + } + + async componentWillMount() { + let response = await fetch("/api/setup/admin_checklist", { credentials: 'same-origin' }); + if (response.status !== 200) { + this.setState({ error: await response.json() }) + } else { + this.setState({ tasks: await response.json() }); + } + } + + render() { + let tasks, nextTask; + if (this.state.tasks) { + tasks = this.state.tasks.map(section => ({ + ...section, + tasks: section.tasks.filter(task => { + if (task.is_next_step) { + nextTask = task; + } + return !task.is_next_step; + }) + })) + } + + return ( + <div className="px2"> + <h2>Getting set up</h2> + <p className="mt1">A few things you can do to get the most out of Metabase.</p> + <LoadingAndErrorWrapper loading={!this.state.tasks} error={this.state.error} > + { () => + <div style={{maxWidth: 468}}> + <TaskSection name="Recommended next step" tasks={[nextTask]} /> + { + tasks.map((section, index) => + <TaskSection {...section} key={index} /> + ) + } + </div> + } + </LoadingAndErrorWrapper> + </div> + ); + } +} diff --git a/frontend/src/metabase/admin/settings/settings.controllers.js b/frontend/src/metabase/admin/settings/settings.controllers.js index ea84bdb27de13fdad48f191130928bfa572b4b24..915a64090bc1913632a9e8e71019339249d423f2 100644 --- a/frontend/src/metabase/admin/settings/settings.controllers.js +++ b/frontend/src/metabase/admin/settings/settings.controllers.js @@ -9,6 +9,10 @@ import MetabaseSettings from 'metabase/lib/settings'; var SettingsAdminControllers = angular.module('metabase.admin.settings.controllers', ['metabase.services']); const SECTIONS = [ + { + name: "Setup", + settings: [] + }, { name: "General", settings: [ diff --git a/frontend/src/metabase/css/core/transitions.css b/frontend/src/metabase/css/core/transitions.css index db0267df26be8456ff6774810d31c43331e5d55d..77c7f40f648e8d13be14f7c9790cf86d57bfbe26 100644 --- a/frontend/src/metabase/css/core/transitions.css +++ b/frontend/src/metabase/css/core/transitions.css @@ -10,3 +10,7 @@ .transition-all { transition: all .2s linear; } + +.transition-border { + transition: border .3s linear; +} diff --git a/frontend/src/metabase/home/components/Homepage.jsx b/frontend/src/metabase/home/components/Homepage.jsx index ee97832659ba236d3d358cc1dc591d10d74aa173..0535dc7fb129cbb9cb80eb546b37ca54b9b1b37e 100644 --- a/frontend/src/metabase/home/components/Homepage.jsx +++ b/frontend/src/metabase/home/components/Homepage.jsx @@ -7,6 +7,7 @@ import Activity from "./Activity.jsx"; import RecentViews from "./RecentViews.jsx"; import Smile from './Smile.jsx'; import NewUserOnboardingModal from './NewUserOnboardingModal.jsx'; +import NextStep from "./NextStep.jsx"; export default class Homepage extends Component { @@ -70,6 +71,7 @@ export default class Homepage extends Component { </div> </div> <div className="Layout-sidebar flex-no-shrink"> + <NextStep /> <RecentViews {...this.props} /> </div> </div> diff --git a/frontend/src/metabase/home/components/NextStep.jsx b/frontend/src/metabase/home/components/NextStep.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8b3ca201f6a86b25e9111d23077a82777ddc512c --- /dev/null +++ b/frontend/src/metabase/home/components/NextStep.jsx @@ -0,0 +1,43 @@ +import React, { Component, PropTypes } from "react"; + +import SidebarSection from "./SidebarSection.jsx"; + +export default class NextStep extends Component { + constructor(props, context) { + super(props, context); + this.state = { + next: null + }; + } + + async componentWillMount() { + let response = await fetch("/api/setup/admin_checklist", { credentials: 'same-origin' }); + if (response.status === 200) { + let sections = await response.json(); + for (let section of sections) { + for (let task of section.tasks) { + if (task.is_next_step) { + this.setState({ next: task }); + break; + } + } + } + } + } + + render() { + const { next } = this.state; + if (next) { + return ( + <SidebarSection title="Setup Tip" icon="clock" extra={<a className="text-brand no-decoration" href="/admin/settings">View all</a>}> + <a className="block p3 no-decoration" href={next.link}> + <h4 className="text-brand text-bold">{next.title}</h4> + <p className="m0 mt1">{next.description}</p> + </a> + </SidebarSection> + ) + } else { + return <span className="hide" /> + } + } +} diff --git a/frontend/src/metabase/home/components/RecentViews.jsx b/frontend/src/metabase/home/components/RecentViews.jsx index 4c2346da4e100844ff447832614afe89681e316c..c323e378cf95f73c7e2d36d4dc635d4bce27d085 100644 --- a/frontend/src/metabase/home/components/RecentViews.jsx +++ b/frontend/src/metabase/home/components/RecentViews.jsx @@ -1,6 +1,7 @@ import React, { Component, PropTypes } from "react"; import Icon from "metabase/components/Icon.jsx"; +import SidebarSection from "./SidebarSection.jsx"; import Urls from "metabase/lib/urls"; export default class RecentViews extends Component { @@ -43,29 +44,23 @@ export default class RecentViews extends Component { let { recentViews } = this.props; return ( - <div className="p2"> - <div className="text-dark-grey clearfix pt2 pb2"> - <Icon className="float-left" name={'clock'} width={18} height={18}></Icon> - <span className="pl1 Sidebar-header">Recently Viewed</span> - </div> - <div className="rounded bg-white" style={{border: '1px solid #E5E5E5'}}> - {recentViews.length > 0 ? - <ul className="p2"> - {recentViews.map((item, index) => - <li key={index} className="py1 ml1 flex align-center clearfix"> - {this.renderIllustration(item)} - <a data-metabase-event={"Recent Views;"+item.model+";"+item.cnt} className="ml1 flex-full link" href={Urls.modelToUrl(item.model, item.model_id)}>{item.model_object.name}</a> - </li> - )} - </ul> - : - <div className="flex flex-column layout-centered text-normal text-grey-2"> - <span className="QuestionCircle mt4">!</span> - <p className="p3 text-centered text-grey-4" style={{ "maxWidth": "100%" }}>You haven't looked at any Dashboards or Questions recently</p> - </div> - } - </div> - </div> + <SidebarSection title="Recently Viewed" icon="clock"> + {recentViews.length > 0 ? + <ul className="p2"> + {recentViews.map((item, index) => + <li key={index} className="py1 ml1 flex align-center clearfix"> + {this.renderIllustration(item)} + <a data-metabase-event={"Recent Views;"+item.model+";"+item.cnt} className="ml1 flex-full link" href={Urls.modelToUrl(item.model, item.model_id)}>{item.model_object.name}</a> + </li> + )} + </ul> + : + <div className="flex flex-column layout-centered text-normal text-grey-2"> + <span className="QuestionCircle mt4">!</span> + <p className="p3 text-centered text-grey-4" style={{ "maxWidth": "100%" }}>You haven't looked at any Dashboards or Questions recently</p> + </div> + } + </SidebarSection> ); } } diff --git a/frontend/src/metabase/home/components/SidebarSection.jsx b/frontend/src/metabase/home/components/SidebarSection.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c8358c8e39ba9ee8ff2c12b226e59e6189db0674 --- /dev/null +++ b/frontend/src/metabase/home/components/SidebarSection.jsx @@ -0,0 +1,17 @@ +import React, { Component, PropTypes } from "react"; + +import Icon from "metabase/components/Icon.jsx"; + +const SidebarSection = ({ title, icon, extra, children }) => + <div className="px2 pt1"> + <div className="text-dark-grey clearfix pt2 pb2"> + <Icon className="float-left" name={icon} width={18} height={18}></Icon> + <span className="pl1 Sidebar-header">{title}</span> + { extra && <span className="float-right">{extra}</span>} + </div> + <div className="rounded bg-white" style={{border: '1px solid #E5E5E5'}}> + { children } + </div> + </div> + +export default SidebarSection; diff --git a/resources/log4j.properties b/resources/log4j.properties index e6d168cbff584a30e28e57663fe782235f6b905d..4bdb765b46d724bb49b18d2a21da944fa5f345ba 100644 --- a/resources/log4j.properties +++ b/resources/log4j.properties @@ -20,5 +20,6 @@ log4j.appender.metabase=metabase.logger.Appender log4j.logger.metabase=INFO log4j.logger.metabase.db=DEBUG log4j.logger.metabase.driver=DEBUG +log4j.logger.metabase.middleware=DEBUG log4j.logger.metabase.query-processor=DEBUG log4j.logger.metabase.sync-database=DEBUG diff --git a/src/metabase/api/setup.clj b/src/metabase/api/setup.clj index 9ed6f9fdc158f29fb91f5041277d507303e58d14..3bde8714ac1635cecba6ef305b45cb0120576881 100644 --- a/src/metabase/api/setup.clj +++ b/src/metabase/api/setup.clj @@ -1,10 +1,12 @@ (ns metabase.api.setup - (:require [compojure.core :refer [defroutes POST]] + (:require [compojure.core :refer [defroutes GET POST]] (metabase.api [common :refer :all] [database :refer [annotation:DBEngine]]) (metabase [db :as db] [driver :as driver] + [email :as email] [events :as events]) + [metabase.integrations.slack :as slack] (metabase.models [database :refer [Database]] [session :refer [Session]] [setting :as setting] @@ -86,4 +88,108 @@ (catch Throwable e (response-invalid :general (.getMessage e)))))) + +;;; Admin Checklist + +(defn- admin-checklist-values [] + (let [has-dbs? (db/exists? Database) + has-dashboards? (db/exists? 'Dashboard) + has-pulses? (db/exists? 'Pulse) + has-labels? (db/exists? 'Label) + has-hidden-tables? (db/exists? 'Table, :visibility_type [:not= nil]) + has-metrics? (db/exists? 'Metric) + has-segments? (db/exists? 'Segment) + num-tables (db/select-one-count 'Table) + num-cards (db/select-one-count 'Card) + num-users (db/select-one-count 'User)] + [{:title "Add a database" + :group "Get connected" + :description "TODO - Write something good here" + :link "/admin/databases/create" + :completed has-dbs? + :triggered :always} + {:title "Set up email" + :group "Get connected" + :description "Add email credentials so you can more easily invite team members and get updates via Pulses." + :link "/admin/settings/?section=Email" + :completed (email/email-configured?) + :triggered :always} + {:title "Set Slack credentials" + :group "Get connected" + :description "Does your team use Slack? If so, you can send automated updates via pulses and ask questions with Metabot." + :link "/admin/settings/?section=Slack" + :completed (slack/slack-configured?) + :triggered :always} + {:title "Invite team members" + :group "Get connected" + :description "Share answers and data with the rest of your team." + :link "/admin/people/" + :completed (>= num-users 1) + :triggered (or has-dashboards? + has-pulses? + (>= num-cards 5))} + {:title "Hide irrelevant tables" + :group "Curate your data" + :description "If your data contains technical or irrelevant info you can hide it." + :link "/admin/datamodel/database" + :completed has-hidden-tables? + :triggered (>= num-tables 20)} + {:title "Organize questions" + :group "Curate your data" + :description "Have a lot of saved questions in Metabase? Create labels to help manage them and add context." + :link "/questions/all" + :completed (not has-labels?) + :triggered (>= num-cards 30)} + {:title "Create metrics" + :group "Curate your data" + :description "Define canonical metrics to make it easier for the rest of your team to get the right answers." + :link "/admin/datamodel/database" + :completed has-metrics? + :triggered (>= num-cards 30)} + {:title "Create segments" + :group "Curate your data" + :description "Keep everyone on the same page by creating canonnical sets of filters anyone can use while asking questions." + :link "/admin/datamodel/database" + :completed has-segments? + :triggered (>= num-cards 30)} + {:title "Create a getting started guide" + :group "Curate your data" + :description "Have a lot of data in Metabase? A getting started guide can help your team find their way around." + :completed false ; TODO - how do we determine this? + :triggered (and (>= num-cards 10) + (>= num-users 5))}])) + +(defn- add-next-step-info + "Add `is_next_step` key to all the STEPS from `admin-checklist`. + The next step is the *first* step where `:triggered` is `true` and `:completed` is `false`." + [steps] + (loop [acc [], found-next-step? false, [step & more] steps] + (if-not step + acc + (let [is-next-step? (boolean (and (not found-next-step?) + (:triggered step) + (not (:completed step)))) + step (-> (assoc step :is_next_step is-next-step?) + (update :triggered boolean))] + (recur (conj acc step) + (or found-next-step? is-next-step?) + more))))) + +(defn- partition-steps-into-groups + "Partition the admin checklist steps into a sequence of groups." + [steps] + (for [[{group-name :group}, :as tasks] (partition-by :group steps)] + {:name group-name + :tasks tasks})) + +(defn- admin-checklist [] + (partition-steps-into-groups (add-next-step-info (admin-checklist-values)))) + +(defendpoint GET "/admin_checklist" + "Return various \"admin checklist\" steps and whether they've been completed. You must be a superuser to see this!" + [] + (check-superuser) + (admin-checklist)) + + (define-routes)