diff --git a/frontend/src/metabase/App.jsx b/frontend/src/metabase/App.jsx index a35f4d951010de50ab4f0468dea7b685b58e6d78..d80a693f399ca7341dfa214aa703600d1cdaa253 100644 --- a/frontend/src/metabase/App.jsx +++ b/frontend/src/metabase/App.jsx @@ -13,3 +13,4 @@ export default class App extends Component { ) } } + diff --git a/frontend/src/metabase/admin/datamodel/containers/MetricApp.jsx b/frontend/src/metabase/admin/datamodel/containers/MetricApp.jsx index a1ac509f7a9e53448137928c268958065f390fe2..1987225464b21ef4c06e5f9e7417ee1ba43c0e01 100644 --- a/frontend/src/metabase/admin/datamodel/containers/MetricApp.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/MetricApp.jsx @@ -8,9 +8,11 @@ import MetricForm from "./MetricForm.jsx"; import { metricEditSelectors } from "../selectors"; import * as actions from "../metadata"; +import { clearRequestState } from "metabase/redux/requests"; const mapDispatchToProps = { ...actions, + clearRequestState, onChangeLocation: push }; @@ -37,9 +39,11 @@ export default class MetricApp extends Component { let { tableMetadata } = this.props; if (metric.id != null) { await this.props.updateMetric(metric); + this.props.clearRequestState({statePath: ['metadata', 'metrics']}); MetabaseAnalytics.trackEvent("Data Model", "Metric Updated"); } else { await this.props.createMetric(metric); + this.props.clearRequestState({statePath: ['metadata', 'metrics']}); MetabaseAnalytics.trackEvent("Data Model", "Metric Created"); } diff --git a/frontend/src/metabase/admin/datamodel/containers/SegmentApp.jsx b/frontend/src/metabase/admin/datamodel/containers/SegmentApp.jsx index 7903451caf57d270b8331647494ffd0fdd857fb0..c751b87bc981e66d14ef3546eb068f85c3e8c349 100644 --- a/frontend/src/metabase/admin/datamodel/containers/SegmentApp.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/SegmentApp.jsx @@ -8,9 +8,11 @@ import SegmentForm from "./SegmentForm.jsx"; import { segmentEditSelectors } from "../selectors"; import * as actions from "../metadata"; +import { clearRequestState } from "metabase/redux/requests"; const mapDispatchToProps = { ...actions, + clearRequestState, onChangeLocation: push }; @@ -37,9 +39,11 @@ export default class SegmentApp extends Component { let { tableMetadata } = this.props; if (segment.id != null) { await this.props.updateSegment(segment); + this.props.clearRequestState({statePath: ['metadata', 'segments']}); MetabaseAnalytics.trackEvent("Data Model", "Segment Updated"); } else { await this.props.createSegment(segment); + this.props.clearRequestState({statePath: ['metadata', 'segments']}); MetabaseAnalytics.trackEvent("Data Model", "Segment Created"); } diff --git a/frontend/src/metabase/components/AccordianList.jsx b/frontend/src/metabase/components/AccordianList.jsx index 3d1c30aae02a55b7bb2b7f34fcf27480d238cf8e..d43753c45399b6e4661df71a972ebc22d9fe6ec5 100644 --- a/frontend/src/metabase/components/AccordianList.jsx +++ b/frontend/src/metabase/components/AccordianList.jsx @@ -41,6 +41,7 @@ export default class AccordianList extends Component { onChange: PropTypes.func, onChangeSection: PropTypes.func, itemIsSelected: PropTypes.func, + itemIsClickable: PropTypes.func, renderItem: PropTypes.func, renderSectionIcon: PropTypes.func, getItemClasses: PropTypes.func, @@ -101,6 +102,14 @@ export default class AccordianList extends Component { return selectedSection === sectionIndex; } + itemIsClickable(item) { + if (this.props.itemIsClickable) { + return this.props.itemIsClickable(item); + } else { + return true; + } + } + itemIsSelected(item) { if (this.props.itemIsSelected) { return this.props.itemIsSelected(item); @@ -144,7 +153,7 @@ export default class AccordianList extends Component { } render() { - const { searchable, sections, showItemArrows, alwaysTogglable, alwaysExpanded, hideSingleSectionTitle, style } = this.props; + const { searchable, searchPlaceholder, sections, showItemArrows, alwaysTogglable, alwaysExpanded, hideSingleSectionTitle, style } = this.props; const { searchText } = this.state; const openSection = this.getOpenSection(); @@ -188,6 +197,7 @@ export default class AccordianList extends Component { <ListSearchField onChange={(val) => this.setState({searchText: val})} searchText={this.state.searchText} + placeholder={searchPlaceholder} /> </div> </div> @@ -199,11 +209,11 @@ export default class AccordianList extends Component { className={cx("p1", { "border-bottom scroll-y scroll-show": !alwaysExpanded })} > { section.items.filter((i) => searchText ? (i.name.toLowerCase().includes(searchText.toLowerCase())) : true ).map((item, itemIndex) => - <li key={itemIndex} className={cx("List-item flex", { 'List-item--selected': this.itemIsSelected(item, itemIndex) }, this.getItemClasses(item, itemIndex))}> + <li key={itemIndex} className={cx("List-item flex", { 'List-item--selected': this.itemIsSelected(item, itemIndex), 'List-item--disabled': !this.itemIsClickable(item) }, this.getItemClasses(item, itemIndex))}> <a - className="flex-full flex align-center px1 cursor-pointer" + className={cx("flex-full flex align-center px1", this.itemIsClickable(item) ? "cursor-pointer" : "cursor-default")} style={{ paddingTop: "0.25rem", paddingBottom: "0.25rem" }} - onClick={this.onChange.bind(this, item)} + onClick={this.itemIsClickable(item) && this.onChange.bind(this, item)} > { this.renderItemIcon(item, itemIndex) } <h4 className="List-item-title ml2">{item.name}</h4> diff --git a/frontend/src/metabase/components/ColumnarSelector.css b/frontend/src/metabase/components/ColumnarSelector.css index 431b64226461aaa365e375332c9e0db8fa6f5031..450c587f3a48150229d9e790d0500c7dfba2ce69 100644 --- a/frontend/src/metabase/components/ColumnarSelector.css +++ b/frontend/src/metabase/components/ColumnarSelector.css @@ -44,12 +44,12 @@ align-items: center; } -.ColumnarSelector-row:hover { +.ColumnarSelector-row:not(.ColumnarSelector-row--disabled):hover { background-color: var(--brand-color) !important; color: white !important; } -.ColumnarSelector-row:hover .ColumnarSelector-description { +.ColumnarSelector-row:not(.ColumnarSelector-row--disabled):hover .ColumnarSelector-description { color: rgba(255,255,255,0.50); } @@ -60,6 +60,10 @@ border-bottom: var(--border-size) var(--border-style) var(--border-color); } +.ColumnarSelector-row--disabled { + color: var(--grey-3); +} + .ColumnarSelector-row .Icon-check { margin-right: var(--margin-2); visibility: hidden; diff --git a/frontend/src/metabase/components/ColumnarSelector.jsx b/frontend/src/metabase/components/ColumnarSelector.jsx index 77bd8914753d3613c703ed6956b13738951a0cd8..c6ccb0e23ed498e898683c744ea57080f1441902 100644 --- a/frontend/src/metabase/components/ColumnarSelector.jsx +++ b/frontend/src/metabase/components/ColumnarSelector.jsx @@ -12,6 +12,10 @@ export default class ColumnarSelector extends Component { }; render() { + const isItemSelected = (item, column) => column.selectedItems ? + column.selectedItems.includes(item) : + column.selectedItem === item; + var columns = this.props.columns.map((column, columnIndex) => { var sectionElements; if (column) { @@ -22,9 +26,11 @@ export default class ColumnarSelector extends Component { var items = section.items.map((item, rowIndex) => { var itemClasses = cx({ 'ColumnarSelector-row': true, - 'ColumnarSelector-row--selected': item === column.selectedItem, + 'ColumnarSelector-row--selected': isItemSelected(item, column), + 'ColumnarSelector-row--disabled': column.disabledOptionIds.includes(item.id), 'flex': true, - 'no-decoration': true + 'no-decoration': true, + 'cursor-default': column.disabledOptionIds.includes(item.id) }); var checkIcon = lastColumn ? <Icon name="check" size={14}/> : null; var descriptionElement; @@ -34,7 +40,7 @@ export default class ColumnarSelector extends Component { } return ( <li key={rowIndex}> - <a className={itemClasses} onClick={column.itemSelectFn.bind(null, item)}> + <a className={itemClasses} onClick={!column.disabledOptionIds.includes(item.id) && column.itemSelectFn.bind(null, item)}> {checkIcon} <div className="flex flex-column"> {column.itemTitleFn(item)} diff --git a/frontend/src/metabase/components/List.css b/frontend/src/metabase/components/List.css index f4fe3be6cc727e2ad2d35422b6d490cc9777be03..b90fb3425882a78356b384da05a5ff2816e8aa5c 100644 --- a/frontend/src/metabase/components/List.css +++ b/frontend/src/metabase/components/List.css @@ -36,7 +36,7 @@ } :local(.headerLink) { - composes: text-brand ml2 from "style"; + composes: text-brand ml2 flex-no-shrink from "style"; font-size: 14px; } diff --git a/frontend/src/metabase/components/Select.jsx b/frontend/src/metabase/components/Select.jsx index 4038bd1fe20685ab42caff6b1d1c015d6f37454c..720ac18b2309033e780dc36db3d6ca450c889773 100644 --- a/frontend/src/metabase/components/Select.jsx +++ b/frontend/src/metabase/components/Select.jsx @@ -145,19 +145,25 @@ export class Option extends Component { class LegacySelect extends Component { static propTypes = { value: PropTypes.any, + values: PropTypes.array, options: PropTypes.array.isRequired, + disabledOptionIds: PropTypes.array, placeholder: PropTypes.string, + emptyPlaceholder: PropTypes.string, onChange: PropTypes.func, optionNameFn: PropTypes.func, optionValueFn: PropTypes.func, className: PropTypes.string, isInitiallyOpen: PropTypes.bool, + disabled: PropTypes.bool, //TODO: clean up hardcoded "AdminSelect" class on trigger to avoid this workaround triggerClasses: PropTypes.string }; static defaultProps = { placeholder: "", + emptyPlaceholder: "Nothing to select", + disabledOptionIds: [], optionNameFn: (option) => option.name, optionValueFn: (option) => option, isInitiallyOpen: false, @@ -168,13 +174,23 @@ class LegacySelect extends Component { } render() { - const { className, value, onChange, options, optionNameFn, optionValueFn, placeholder, isInitiallyOpen } = this.props; + const { className, value, values, onChange, options, disabledOptionIds, optionNameFn, optionValueFn, placeholder, emptyPlaceholder, isInitiallyOpen, disabled } = this.props; - var selectedName = value ? optionNameFn(value) : placeholder; + var selectedName = value ? + optionNameFn(value) : + options && options.length > 0 ? + placeholder : + emptyPlaceholder; var triggerElement = ( - <div className={"flex align-center " + (!value ? " text-grey-3" : "")}> - <span className="mr1">{selectedName}</span> + <div className={cx("flex align-center", !value && (!values || values.length === 0) ? " text-grey-2" : "")}> + { values && values.length !== 0 ? + values + .map(value => optionNameFn(value)) + .sort() + .map((name, index) => <span key={index} className="mr1">{`${name}${index !== (values.length - 1) ? ', ' : ''}`}</span>) : + <span className="mr1">{selectedName}</span> + } <Icon className="flex-align-right" name="chevrondown" size={12}/> </div> ); @@ -190,16 +206,22 @@ class LegacySelect extends Component { var columns = [ { selectedItem: value, + selectedItems: values, sections: sections, + disabledOptionIds: disabledOptionIds, itemTitleFn: optionNameFn, itemDescriptionFn: (item) => item.description, itemSelectFn: (item) => { - onChange(optionValueFn(item)) - this.toggle(); + onChange(optionValueFn(item)); + if (!values) { + this.toggle(); + } } } ]; + const disablePopover = disabled || !options || options.length === 0; + return ( <PopoverWithTrigger ref="popover" @@ -207,6 +229,7 @@ class LegacySelect extends Component { triggerElement={triggerElement} triggerClasses={this.props.triggerClasses || cx("AdminSelect", this.props.className)} isInitiallyOpen={isInitiallyOpen} + disabled={disablePopover} > <div onClick={(e) => e.stopPropagation()}> <ColumnarSelector diff --git a/frontend/src/metabase/components/Triggerable.jsx b/frontend/src/metabase/components/Triggerable.jsx index 87d21025299e539495e7ab1cc95e2c53e5f817cf..084e52a3ab5e627aafbe0d006d68da40a7f32e35 100644 --- a/frontend/src/metabase/components/Triggerable.jsx +++ b/frontend/src/metabase/components/Triggerable.jsx @@ -106,7 +106,7 @@ export default ComposedComponent => class extends Component { } return ( - <a ref="trigger" onClick={() => this.toggle()} className={cx("no-decoration", triggerClasses, isOpen ? triggerClassesOpen : null)}> + <a ref="trigger" onClick={!this.props.disabled && (() => this.toggle())} className={cx("no-decoration", triggerClasses, isOpen ? triggerClassesOpen : null, this.props.disabled ? 'cursor-default' : null)}> {triggerElement} <ComposedComponent {...this.props} diff --git a/frontend/src/metabase/css/components/buttons.css b/frontend/src/metabase/css/components/buttons.css index 8b32aa7f1f8349f5721c7f062235f4131ed63418..bc391150739ed2b4baa3238b327d985c0183668d 100644 --- a/frontend/src/metabase/css/components/buttons.css +++ b/frontend/src/metabase/css/components/buttons.css @@ -54,6 +54,11 @@ font-size: 0.8rem; } +.Button--large { + padding: 0.8rem 1.25rem; + font-size: 1rem; +} + .Button-normal { font-weight: normal; } @@ -95,7 +100,12 @@ .Button--purple { color: white; background-color: #A989C5; - border: 1px solid #885AB1; + border: 1px solid #A989C5; +} + +.Button--purple:hover { + background-color: #885AB1; + border-color: #885AB1; } .Button--borderless { diff --git a/frontend/src/metabase/css/core/bordered.css b/frontend/src/metabase/css/core/bordered.css index eceb8bd95e3463f86e101166d01fbd7a53d1b4c3..fded00ab191bfb6c545dd966e88be46f42605189 100644 --- a/frontend/src/metabase/css/core/bordered.css +++ b/frontend/src/metabase/css/core/bordered.css @@ -83,7 +83,7 @@ border-color: var(--success-color) !important; } -.border-brand { +.border-brand, :local(.border-brand) { border-color: var(--brand-color) !important; } diff --git a/frontend/src/metabase/css/core/colors.css b/frontend/src/metabase/css/core/colors.css index 709307322c22f430f5a2925d62758cff392d9efd..da9c96bcf719d348f57a68180c942d530821b3af 100644 --- a/frontend/src/metabase/css/core/colors.css +++ b/frontend/src/metabase/css/core/colors.css @@ -151,7 +151,9 @@ .bg-grey-3 { background-color: var(--grey-3) } .bg-grey-4 { background-color: var(--grey-4) } -.text-dark, :local(.text-dark) { color: var(--dark-color); } +.text-dark, :local(.text-dark) { + color: var(--dark-color); +} /* white - move to bottom for specificity since its often used on hovers, etc */ .text-white, :local(.text-white), diff --git a/frontend/src/metabase/css/core/cursor.css b/frontend/src/metabase/css/core/cursor.css index a38c4d2c2df90627321c0945fe6f750d106c124d..2de366c0f853a38828613c1e03978ef96e503597 100644 --- a/frontend/src/metabase/css/core/cursor.css +++ b/frontend/src/metabase/css/core/cursor.css @@ -1,3 +1,7 @@ -:local .cursor-pointer { +.cursor-pointer, :local(.cursor-pointer) { cursor: pointer; } + +.cursor-default, :local(.cursor-default) { + cursor: default; +} diff --git a/frontend/src/metabase/css/core/text.css b/frontend/src/metabase/css/core/text.css index 88cec0eb838d0c136e0b15e99527f5297c851252..3e2aa7ce4044735cfa268425efb05c8471db5955 100644 --- a/frontend/src/metabase/css/core/text.css +++ b/frontend/src/metabase/css/core/text.css @@ -23,7 +23,7 @@ /* left */ -.text-left { text-align: left; } +.text-left, :local(.text-left) { text-align: left; } @media screen and (--breakpoint-min-sm) { .sm-text-left { text-align: left; } @@ -43,7 +43,7 @@ /* right */ -.text-right { text-align: right; } +.text-right, :local(.text-right) { text-align: right; } @media screen and (--breakpoint-min-sm) { .sm-text-right { text-align: right; } @@ -91,11 +91,11 @@ } .text-underline { - text-decoration: underline;; + text-decoration: underline; } .text-underline-hover:hover { - text-decoration: underline;; + text-decoration: underline; } .text-ellipsis { @@ -111,3 +111,7 @@ line-height: 1.4em; white-space: pre; } + +.text-measure { + max-width: 620px; +} diff --git a/frontend/src/metabase/css/home.css b/frontend/src/metabase/css/home.css index 75ed5e8ae0a9b06c27d1bead0291638aec773932..4e301f009750f23d0f3afde5ba30955f0bb5028a 100644 --- a/frontend/src/metabase/css/home.css +++ b/frontend/src/metabase/css/home.css @@ -168,10 +168,6 @@ word-wrap: break-word; } -.cursor-pointer { - cursor: pointer; -} - .tooltip { position: absolute; background-color: #fff; diff --git a/frontend/src/metabase/css/query_builder.css b/frontend/src/metabase/css/query_builder.css index 00d5025a1402bb179976f3d85dab4f5733acdd0f..cd06f972dccf04602340ff217ac2910845902ba4 100644 --- a/frontend/src/metabase/css/query_builder.css +++ b/frontend/src/metabase/css/query_builder.css @@ -663,6 +663,10 @@ margin-bottom: 2px; } +.List-item--disabled .List-item-title { + color: var(--grey-3); +} + .List-item--segment .Icon, .List-item--segment .List-item-title { color: var(--purple-color); @@ -673,7 +677,7 @@ color: var(--brand-color); } -.List-item:hover, +.List-item:not(.List-item--disabled):hover, .List-item--selected { background-color: currentColor; border-color: rgba(0,0,0,0.2); @@ -684,12 +688,12 @@ color: var(--default-font-color); } -.List-item:hover .List-item-title, +.List-item:not(.List-item--disabled):hover .List-item-title, .List-item--selected .List-item-title { color: white; } -.List-item:hover .Icon, +.List-item:not(.List-item--disabled):hover .Icon, .List-item--selected .Icon { color: white; } @@ -699,7 +703,7 @@ visibility: hidden; } -.List-item:hover .FieldList-grouping-trigger, +.List-item:not(.List-item--disabled):hover .FieldList-grouping-trigger, .List-item--selected .FieldList-grouping-trigger { visibility: visible; border-left: 2px solid rgba(0,0,0,0.1); diff --git a/frontend/src/metabase/dashboard/dashboard.js b/frontend/src/metabase/dashboard/dashboard.js index 61c804bd86ed631f48b298f249ae32edffe833f3..351435beac696f439574d426b09a5afc7d1a1f68 100644 --- a/frontend/src/metabase/dashboard/dashboard.js +++ b/frontend/src/metabase/dashboard/dashboard.js @@ -34,6 +34,7 @@ export const FETCH_DASHBOARD = "metabase/dashboard/FETCH_DASHBOARD"; export const FETCH_DASHBOARDS = "metabase/dashboard/FETCH_DASHBOARDS"; export const CREATE_DASHBOARD = "metabase/dashboard/CREATE_DASHBOARD"; export const SAVE_DASHBOARD = "metabase/dashboard/SAVE_DASHBOARD"; +export const UPDATE_DASHBOARD = "metabase/dashboard/UPDATE_DASHBOARD"; export const DELETE_DASHBOARD = "metabase/dashboard/DELETE_DASHBOARD"; export const SET_DASHBOARD_ATTRIBUTES = "metabase/dashboard/SET_DASHBOARD_ATTRIBUTES"; @@ -269,6 +270,38 @@ export const saveDashboard = createThunkAction(SAVE_DASHBOARD, function(dashId) }; }); +export const updateDashboard = createThunkAction(UPDATE_DASHBOARD, (dashboard) => + async (dispatch, getState) => { + const { + id, + name, + description, + public_perms, + parameters, + caveats, + points_of_interest, + show_in_getting_started + } = dashboard; + + const cleanDashboard = { + id, + name, + description, + public_perms, + parameters, + caveats, + points_of_interest, + show_in_getting_started + }; + + const updatedDashboard = await DashboardApi.update(cleanDashboard); + + MetabaseAnalytics.trackEvent("Dashboard", "Update"); + + return updatedDashboard; + } +); + export const fetchDashboards = createAction(FETCH_DASHBOARDS, () => DashboardApi.list({ f: "all" }) ); @@ -420,6 +453,7 @@ const dashboardListing = handleActions({ [CREATE_DASHBOARD]: (state, { payload }) => state.concat(payload), [DELETE_DASHBOARD]: (state, { payload }) => state.filter(d => d.id !== payload), [SAVE_DASHBOARD]: (state, { payload }) => state.map(d => d.id === payload.id ? payload : d), + [UPDATE_DASHBOARD]: (state, { payload }) => state.map(d => d.id === payload.id ? payload : d), }, []); export default combineReducers({ diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index 0f0d5dafd046f196d0c371d1d9348b9cd0a36610..4fb6f764b36fd4e1deaed579790fa0ad3336d349 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -53,6 +53,7 @@ export var ICON_PATHS = { cursor_move: 'M14.8235294,14.8235294 L14.8235294,6.58823529 L17.1764706,6.58823529 L17.1764706,14.8235294 L25.4117647,14.8235294 L25.4117647,17.1764706 L17.1764706,17.1764706 L17.1764706,25.4117647 L14.8235294,25.4117647 L14.8235294,17.1764706 L6.58823529,17.1764706 L6.58823529,14.8235294 L14.8235294,14.8235294 L14.8235294,14.8235294 Z M16,0 L20.1176471,6.58823529 L11.8823529,6.58823529 L16,0 Z M11.8823529,25.4117647 L20.1176471,25.4117647 L16,32 L11.8823529,25.4117647 Z M32,16 L25.4117647,20.1176471 L25.4117647,11.8823529 L32,16 Z M6.58823529,11.8823529 L6.58823529,20.1176471 L0,16 L6.58823529,11.8823529 Z', cursor_resize: 'M17.4017952,6.81355995 L15.0488541,6.81355995 L15.0488541,25.6370894 L17.4017952,25.6370894 L17.4017952,6.81355995 Z M16.2253247,0.225324657 L20.3429717,6.81355995 L12.1076776,6.81355995 L16.2253247,0.225324657 Z M12.1076776,25.6370894 L20.3429717,25.6370894 L16.2253247,32.2253247 L12.1076776,25.6370894 Z', database: 'M1.18285296e-08,10.5127919 C-1.47856568e-08,7.95412848 1.18285298e-08,4.57337284 1.18285298e-08,4.57337284 C1.18285298e-08,4.57337284 1.58371041,5.75351864e-10 15.6571342,0 C29.730558,-5.7535027e-10 31.8900148,4.13849684 31.8900148,4.57337284 L31.8900148,10.4843058 C31.8900148,10.4843058 30.4448001,15.1365942 16.4659751,15.1365944 C2.48715012,15.1365947 2.14244494e-08,11.4353349 1.18285296e-08,10.5127919 Z M0.305419478,21.1290071 C0.305419478,21.1290071 0.0405133833,21.2033291 0.0405133833,21.8492606 L0.0405133833,27.3032816 C0.0405133833,27.3032816 1.46515486,31.941655 15.9641228,31.941655 C30.4630908,31.941655 32,27.3446712 32,27.3446712 C32,27.3446712 32,21.7986104 32,21.7986105 C32,21.2073557 31.6620557,21.0987647 31.6620557,21.0987647 C31.6620557,21.0987647 29.7146434,25.22314 16.0318829,25.22314 C2.34912233,25.22314 0.305419478,21.1290071 0.305419478,21.1290071 Z M0.305419478,12.656577 C0.305419478,12.656577 0.0405133833,12.730899 0.0405133833,13.3768305 L0.0405133833,18.8308514 C0.0405133833,18.8308514 1.46515486,23.4692249 15.9641228,23.4692249 C30.4630908,23.4692249 32,18.8722411 32,18.8722411 C32,18.8722411 32,13.3261803 32,13.3261803 C32,12.7349256 31.6620557,12.6263346 31.6620557,12.6263346 C31.6620557,12.6263346 29.7146434,16.7507099 16.0318829,16.7507099 C2.34912233,16.7507099 0.305419478,12.656577 0.305419478,12.656577 Z', + dashboard: 'M32,29 L32,4 L32,0 L0,0 L0,8 L28,8 L28,28 L4,28 L4,8 L0,8 L0,29.5 L0,32 L32,32 L32,29 Z M7.27272727,18.9090909 L17.4545455,18.9090909 L17.4545455,23.2727273 L7.27272727,23.2727273 L7.27272727,18.9090909 Z M7.27272727,12.0909091 L24.7272727,12.0909091 L24.7272727,16.4545455 L7.27272727,16.4545455 L7.27272727,12.0909091 Z M20.3636364,18.9090909 L24.7272727,18.9090909 L24.7272727,23.2727273 L20.3636364,23.2727273 L20.3636364,18.9090909 Z', dashboards: 'M17,5.49100518 L17,10.5089948 C17,10.7801695 17.2276528,11 17.5096495,11 L26.4903505,11 C26.7718221,11 27,10.7721195 27,10.5089948 L27,5.49100518 C27,5.21983051 26.7723472,5 26.4903505,5 L17.5096495,5 C17.2281779,5 17,5.22788048 17,5.49100518 Z M18.5017326,14 C18.225722,14 18,13.77328 18,13.4982674 L18,26.5017326 C18,26.225722 18.22672,26 18.5017326,26 L5.49826741,26 C5.77427798,26 6,26.22672 6,26.5017326 L6,13.4982674 C6,13.774278 5.77327997,14 5.49826741,14 L18.5017326,14 Z M14.4903505,6 C14.2278953,6 14,5.78028538 14,5.49100518 L14,10.5089948 C14,10.2167107 14.2224208,10 14.4903505,10 L5.50964952,10 C5.77210473,10 6,10.2197146 6,10.5089948 L6,5.49100518 C6,5.78328929 5.77757924,6 5.50964952,6 L14.4903505,6 Z M26.5089948,22 C26.2251201,22 26,21.7774008 26,21.4910052 L26,26.5089948 C26,26.2251201 26.2225992,26 26.5089948,26 L21.4910052,26 C21.7748799,26 22,26.2225992 22,26.5089948 L22,21.4910052 C22,21.7748799 21.7774008,22 21.4910052,22 L26.5089948,22 Z M26.5089948,14 C26.2251201,14 26,13.7774008 26,13.4910052 L26,18.5089948 C26,18.2251201 26.2225992,18 26.5089948,18 L21.4910052,18 C21.7748799,18 22,18.2225992 22,18.5089948 L22,13.4910052 C22,13.7748799 21.7774008,14 21.4910052,14 L26.5089948,14 Z M26.4903505,6 C26.2278953,6 26,5.78028538 26,5.49100518 L26,10.5089948 C26,10.2167107 26.2224208,10 26.4903505,10 L17.5096495,10 C17.7721047,10 18,10.2197146 18,10.5089948 L18,5.49100518 C18,5.78328929 17.7775792,6 17.5096495,6 L26.4903505,6 Z M5,13.4982674 L5,26.5017326 C5,26.7769181 5.21990657,27 5.49826741,27 L18.5017326,27 C18.7769181,27 19,26.7800934 19,26.5017326 L19,13.4982674 C19,13.2230819 18.7800934,13 18.5017326,13 L5.49826741,13 C5.22308192,13 5,13.2199066 5,13.4982674 Z M5,5.49100518 L5,10.5089948 C5,10.7801695 5.22765279,11 5.50964952,11 L14.4903505,11 C14.7718221,11 15,10.7721195 15,10.5089948 L15,5.49100518 C15,5.21983051 14.7723472,5 14.4903505,5 L5.50964952,5 C5.22817786,5 5,5.22788048 5,5.49100518 Z M21,21.4910052 L21,26.5089948 C21,26.7801695 21.2278805,27 21.4910052,27 L26.5089948,27 C26.7801695,27 27,26.7721195 27,26.5089948 L27,21.4910052 C27,21.2198305 26.7721195,21 26.5089948,21 L21.4910052,21 C21.2198305,21 21,21.2278805 21,21.4910052 Z M21,13.4910052 L21,18.5089948 C21,18.7801695 21.2278805,19 21.4910052,19 L26.5089948,19 C26.7801695,19 27,18.7721195 27,18.5089948 L27,13.4910052 C27,13.2198305 26.7721195,13 26.5089948,13 L21.4910052,13 C21.2198305,13 21,13.2278805 21,13.4910052 Z', document: 'M29,10.1052632 L29,28.8325291 C29,30.581875 27.5842615,32 25.8337327,32 L7.16626728,32 C5.41758615,32 4,30.5837102 4,28.8441405 L4,3.15585953 C4,1.41292644 5.42339685,9.39605581e-15 7.15970573,8.42009882e-15 L20.713352,8.01767853e-16 L20.713352,8.42105263 L22.3846872,8.42105263 L22.3846872,0.310375032 L28.7849894,8.42105263 L20.713352,8.42105263 L20.713352,10.1052632 L29,10.1052632 Z M7.3426704,12.8000006 L25.7273576,12.8000006 L25.7273576,14.4842112 L7.3426704,14.4842112 L7.3426704,12.8000006 Z M7.3426704,17.3473687 L25.7273576,17.3473687 L25.7273576,19.0315793 L7.3426704,19.0315793 L7.3426704,17.3473687 Z M7.3426704,21.8947352 L25.7273576,21.8947352 L25.7273576,23.5789458 L7.3426704,23.5789458 L7.3426704,21.8947352 Z M7.43137255,26.2736849 L16.535014,26.2736849 L16.535014,27.9578954 L7.43137255,27.9578954 L7.43137255,26.2736849 Z', download: { diff --git a/frontend/src/metabase/lib/redux.js b/frontend/src/metabase/lib/redux.js index 1492e39cd9c70c7228a14942e7dc9bf5d73d48d3..3684c78eed27a6722ced8c5e6440b2b60492c917 100644 --- a/frontend/src/metabase/lib/redux.js +++ b/frontend/src/metabase/lib/redux.js @@ -1,5 +1,6 @@ import moment from "moment"; import _ from "underscore"; +import i from "icepick"; import { createStore as originalCreateStore, applyMiddleware, compose } from "redux"; import promise from 'redux-promise'; @@ -11,6 +12,8 @@ import { createAngularHistory } from "./createAngularHistory"; import { reduxReactRouter } from 'redux-router'; +import { setRequestState, clearRequestState } from "metabase/redux/requests"; + // convienence export { combineReducers } from "redux"; export { handleActions, createAction } from "redux-actions"; @@ -91,12 +94,72 @@ export function momentifyObjectsTimestamps(objects, keys) { return _.mapObject(objects, o => momentifyTimestamps(o, keys)); } +//filters out angular cruft in resource list +export const cleanResources = (resources) => resources + .filter(resource => resource.id !== undefined); + //filters out angular cruft and turns into id indexed map -export const resourceListToMap = (resources) => resources - .filter(resource => resource.id !== undefined) +export const resourceListToMap = (resources) => cleanResources(resources) .reduce((map, resource) => Object.assign({}, map, {[resource.id]: resource}), {}); //filters out angular cruft in resource export const cleanResource = (resource) => Object.keys(resource) .filter(key => key.charAt(0) !== "$") .reduce((map, key) => Object.assign({}, map, {[key]: resource[key]}), {}); + +export const fetchData = async ({ + dispatch, + getState, + requestStatePath, + existingStatePath, + getData, + reload +}) => { + const existingData = i.getIn(getState(), existingStatePath); + const statePath = requestStatePath.concat(['fetch']); + try { + const requestState = i.getIn(getState(), ["requests", ...statePath]); + if (!requestState || requestState.error || reload) { + dispatch(setRequestState({ statePath, state: "LOADING" })); + const data = await getData(); + dispatch(setRequestState({ statePath, state: "LOADED" })); + + return data; + } + + return existingData; + } + catch(error) { + dispatch(setRequestState({ statePath, error })); + console.error(error); + return existingData; + } +} + +export const updateData = async ({ + dispatch, + getState, + requestStatePath, + existingStatePath, + // specify any request paths that need to be invalidated after this update + dependentRequestStatePaths, + putData +}) => { + const existingData = i.getIn(getState(), existingStatePath); + const statePath = requestStatePath.concat(['update']); + try { + dispatch(setRequestState({ statePath, state: "LOADING" })); + const data = await putData(); + dispatch(setRequestState({ statePath, state: "LOADED" })); + + (dependentRequestStatePaths || []) + .forEach(statePath => dispatch(clearRequestState({ statePath }))); + + return data; + } + catch(error) { + dispatch(setRequestState({ statePath, error })); + console.error(error); + return existingData; + } +} \ No newline at end of file diff --git a/frontend/src/metabase/query_builder/DataSelector.jsx b/frontend/src/metabase/query_builder/DataSelector.jsx index 2b51ed2b185be2258395bff895e90fad6727e966..2261e80ef407a0383f17f6b4a9c0c403089ae651 100644 --- a/frontend/src/metabase/query_builder/DataSelector.jsx +++ b/frontend/src/metabase/query_builder/DataSelector.jsx @@ -17,17 +17,23 @@ export default class DataSelector extends Component { this.state = { databases: null, selectedSchema: null, - showTablePicker: true + showTablePicker: true, + showSegmentPicker: props.segments && props.segments.length > 0 } - _.bindAll(this, "onChangeDatabase", "onChangeSchema", "onChangeTable", "onBack"); + _.bindAll(this, "onChangeDatabase", "onChangeSchema", "onChangeTable", "onChangeSegment", "onBack"); } static propTypes = { query: PropTypes.object.isRequired, databases: PropTypes.array.isRequired, + tables: PropTypes.array, + segments: PropTypes.array, + disabledTableIds: PropTypes.array, + disabledSegmentIds: PropTypes.array, setDatabaseFn: PropTypes.func.isRequired, setSourceTableFn: PropTypes.func, + setSourceSegmentFn: PropTypes.func, isInitiallyOpen: PropTypes.bool }; @@ -38,7 +44,7 @@ export default class DataSelector extends Component { componentWillMount() { this.componentWillReceiveProps(this.props); - if (this.props.databases.length === 1) { + if (this.props.databases.length === 1 && !this.props.segments) { setTimeout(() => this.onChangeDatabase(0)); } } @@ -86,6 +92,14 @@ export default class DataSelector extends Component { this.refs.popover.toggle(); } + onChangeSegment(item) { + if (item.segment != null) { + this.props.setSourceSegmentFn(item.segment.id); + } + + this.refs.popover.toggle(); + } + onChangeSchema(schema) { this.setState({ selectedSchema: schema, @@ -93,9 +107,16 @@ export default class DataSelector extends Component { }); } + onChangeSegmentSection() { + this.setState({ + showSegmentPicker: true + }); + } + onBack() { this.setState({ - showTablePicker: false + showTablePicker: false, + showSegmentPicker: false }); } @@ -115,6 +136,10 @@ export default class DataSelector extends Component { }); } + getSegmentId() { + return this.props.query.segment; + } + getDatabaseId() { return this.props.query.database; } @@ -176,13 +201,52 @@ export default class DataSelector extends Component { ); } + renderSegmentAndDatabasePicker() { + const { selectedSchema } = this.state; + + const segmentItem = [{ name: 'Segments', items: [], icon: 'segment'}]; + + const sections = segmentItem.concat(this.state.databases.map(database => { + return { + name: database.name, + items: database.schemas.length > 1 ? database.schemas : [] + }; + })); + + // FIXME: this seems a bit brittle and hard to follow + let openSection = selectedSchema && (_.findIndex(this.state.databases, (db) => _.find(db.schemas, selectedSchema)) + segmentItem.length); + if (openSection >= 0 && this.state.databases[openSection - segmentItem.length] && this.state.databases[openSection - segmentItem.length].schemas.length === 1) { + openSection = -1; + } + + return ( + <AccordianList + key="schemaPicker" + className="text-brand" + sections={sections} + onChange={this.onChangeSchema} + onChangeSection={(index) => index === 0 ? + this.onChangeSegmentSection() : + this.onChangeDatabase(index - segmentItem.length) + } + itemIsSelected={(schema) => this.state.selectedSchema === schema} + renderSectionIcon={(section, sectionIndex) => <Icon className="Icon text-default" name={section.icon || "database"} size={18} />} + renderItemIcon={() => <Icon name="folder" size={16} />} + initiallyOpenSection={openSection} + showItemArrows={true} + alwaysTogglable={true} + /> + ); + } + renderTablePicker() { const schema = this.state.selectedSchema; const hasMultipleDatabases = this.props.databases.length > 1; + const hasSegments = !!this.props.segments; let header = ( <span className="flex align-center"> - <span className={cx("flex align-center text-slate", { "cursor-pointer": hasMultipleDatabases })} onClick={hasMultipleDatabases && this.onBack}> - { hasMultipleDatabases && <Icon name="chevronleft" size={18} /> } + <span className={cx("flex align-center text-slate", { "cursor-pointer": hasMultipleDatabases || hasSegments })} onClick={(hasMultipleDatabases || hasSegments) && this.onBack}> + { (hasMultipleDatabases || hasSegments) && <Icon name="chevronleft" size={18} /> } <span className="ml1">{schema.database.name}</span> </span> { schema.name && @@ -207,11 +271,13 @@ export default class DataSelector extends Component { } else { let sections = [{ name: header, - items: schema.tables.map(table => ({ - name: table.display_name, - table: table, - database: schema.database - })) + items: schema.tables + .map(table => ({ + name: table.display_name, + disabled: this.props.disabledTableIds && this.props.disabledTableIds.includes(table.id), + table: table, + database: schema.database + })) }]; return ( <AccordianList @@ -221,7 +287,7 @@ export default class DataSelector extends Component { searchable={true} onChange={this.onChangeTable} itemIsSelected={(item) => item.table ? item.table.id === this.getTableId() : false} - itemIsClickable={(item) => item.table} + itemIsClickable={(item) => item.table && !item.disabled} renderItemIcon={(item) => item.table ? <Icon name="table2" size={18} /> : null} hideSingleSectionTitle={true} /> @@ -229,6 +295,57 @@ export default class DataSelector extends Component { } } + //TODO: refactor this. lots of shared code with renderTablePicker() + renderSegmentPicker() { + const { segments } = this.props; + const header = ( + <span className="flex align-center"> + <span className="flex align-center text-slate cursor-pointer" onClick={this.onBack}> + <Icon name="chevronleft" size={18} /> + <span className="ml1">Segments</span> + </span> + </span> + ); + + if (!segments || segments.length === 0) { + return ( + <section className="List-section List-section--open" style={{width: '300px'}}> + <div className="p1 border-bottom"> + <div className="px1 py1 flex align-center"> + <h3 className="text-default">{header}</h3> + </div> + </div> + <div className="p4 text-centered">No segments were found.</div> + </section> + ); + } + + const sections = [{ + name: header, + items: segments + .map(segment => ({ + name: segment.name, + segment: segment, + disabled: this.props.disabledSegmentIds && this.props.disabledSegmentIds.includes(segment.id) + })) + }]; + + return ( + <AccordianList + key="segmentPicker" + className="text-brand" + sections={sections} + searchable={true} + searchPlaceholder="Find a segment" + onChange={this.onChangeSegment} + itemIsSelected={(item) => item.segment ? item.segment.id === this.getSegmentId() : false} + itemIsClickable={(item) => item.segment && !item.disabled} + renderItemIcon={(item) => item.segment ? <Icon name="segment" size={18} /> : null} + hideSingleSectionTitle={true} + /> + ); + } + render() { const { databases } = this.props; @@ -238,7 +355,17 @@ export default class DataSelector extends Component { var table = _.find(database.tables, (table) => table.id === tableId); var content; - if (this.props.includeTables) { + if (this.props.includeTables && this.props.segments) { + const segmentId = this.getSegmentId(); + const segment = _.find(this.props.segments, (segment) => segment.id === segmentId); + if (table) { + content = <span className="text-grey no-decoration">{table.display_name || table.name}</span>; + } else if (segment) { + content = <span className="text-grey no-decoration">{segment.name}</span>; + } else { + content = <span className="text-grey-4 no-decoration">Pick a segment or table</span>; + } + } else if (this.props.includeTables) { if (table) { content = <span className="text-grey no-decoration">{table.display_name || table.name}</span>; } else { @@ -253,9 +380,9 @@ export default class DataSelector extends Component { } var triggerElement = ( - <span className="px2 py2 text-bold cursor-pointer text-default"> + <span className={this.props.className || "px2 py2 text-bold cursor-pointer text-default"} style={this.props.style}> {content} - <Icon className="ml1" name="chevrondown" size={8}/> + <Icon className="ml1" name="chevrondown" size={this.props.triggerIconSize || 8}/> </span> ) @@ -265,14 +392,17 @@ export default class DataSelector extends Component { isInitiallyOpen={this.props.isInitiallyOpen} triggerElement={triggerElement} triggerClasses="flex align-center" - horizontalAttachments={["left"]} + horizontalAttachments={this.props.segments ? ["center", "left", "right"] : ["left"]} > { !this.props.includeTables ? - this.renderDatabasePicker() - : this.state.selectedSchema && this.state.showTablePicker ? - this.renderTablePicker() - : - this.renderDatabaseSchemaPicker() + this.renderDatabasePicker() : + this.state.selectedSchema && this.state.showTablePicker ? + this.renderTablePicker() : + this.props.segments ? + this.state.showSegmentPicker ? + this.renderSegmentPicker() : + this.renderSegmentAndDatabasePicker() : + this.renderDatabaseSchemaPicker() } </PopoverWithTrigger> ); diff --git a/frontend/src/metabase/redux/metadata.js b/frontend/src/metabase/redux/metadata.js index b710190bcbd4ea148c0f8c9c39805ef5a1c10fbf..5543b536e7e0a84d873ced1369b895916121669d 100644 --- a/frontend/src/metabase/redux/metadata.js +++ b/frontend/src/metabase/redux/metadata.js @@ -4,7 +4,9 @@ import { AngularResourceProxy, createThunkAction, resourceListToMap, - cleanResource + cleanResource, + fetchData, + updateData, } from "metabase/lib/redux"; import { normalize, Schema, arrayOf } from 'normalizr'; @@ -12,16 +14,19 @@ import i from "icepick"; import _ from "underscore"; import { augmentDatabase, augmentTable } from "metabase/lib/table"; -import { setRequestState, clearRequestState } from "./requests"; const MetabaseApi = new AngularResourceProxy("Metabase", ["db_list", "db_update", "db_metadata", "table_list", "table_update", "table_query_metadata", "field_update"]); -const MetricApi = new AngularResourceProxy("Metric", ["list", "update"]); +const MetricApi = new AngularResourceProxy("Metric", ["list", "update", "update_important_fields"]); const SegmentApi = new AngularResourceProxy("Segment", ["list", "update"]); const RevisionApi = new AngularResourceProxy("Revisions", ["get"]); +const database_list = new Schema('database_list'); const database = new Schema('databases'); const table = new Schema('tables'); const field = new Schema('fields'); +database_list.define({ + databases: arrayOf(database) +}); database.define({ tables: arrayOf(table) }); @@ -29,45 +34,6 @@ table.define({ fields: arrayOf(field) }); -export const fetchData = async ({dispatch, getState, requestStatePath, existingStatePath, getData, reload}) => { - const existingData = i.getIn(getState(), existingStatePath); - const statePath = requestStatePath.concat(['fetch']); - try { - const requestState = i.getIn(getState(), ["requests", ...statePath]); - if (!requestState || requestState.error || reload) { - dispatch(setRequestState({ statePath, state: "LOADING" })); - const data = await getData(); - dispatch(setRequestState({ statePath, state: "LOADED" })); - - return data; - } - - return existingData; - } - catch(error) { - dispatch(setRequestState({ statePath, error })); - console.error(error); - return existingData; - } -} - -export const updateData = async ({dispatch, getState, requestStatePath, existingStatePath, putData}) => { - const existingData = i.getIn(getState(), existingStatePath); - const statePath = requestStatePath.concat(['update']); - try { - dispatch(setRequestState({ statePath, state: "LOADING" })); - const data = await putData(); - dispatch(setRequestState({ statePath, state: "LOADED" })); - - return data; - } - catch(error) { - dispatch(setRequestState({ statePath, error })); - console.error(error); - return existingData; - } -} - const FETCH_METRICS = "metabase/metadata/FETCH_METRICS"; export const fetchMetrics = createThunkAction(FETCH_METRICS, (reload = false) => { return async (dispatch, getState) => { @@ -79,7 +45,14 @@ export const fetchMetrics = createThunkAction(FETCH_METRICS, (reload = false) => return metricMap; }; - return await fetchData({dispatch, getState, requestStatePath, existingStatePath, getData, reload}); + return await fetchData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + getData, + reload + }); }; }); @@ -88,8 +61,8 @@ export const updateMetric = createThunkAction(UPDATE_METRIC, function(metric) { return async (dispatch, getState) => { const requestStatePath = ["metadata", "metrics", metric.id]; const existingStatePath = ["metadata", "metrics"]; + const dependentRequestStatePaths = [['metadata', 'revisions', 'metric', metric.id]]; const putData = async () => { - //FIXME: need to clear requestState for revisions for it to reload const updatedMetric = await MetricApi.update(metric); const cleanMetric = cleanResource(updatedMetric); const existingMetrics = i.getIn(getState(), existingStatePath); @@ -97,11 +70,38 @@ export const updateMetric = createThunkAction(UPDATE_METRIC, function(metric) { const mergedMetric = {...existingMetric, ...cleanMetric}; - dispatch(clearRequestState({statePath: ['metadata', 'revisions', 'metric', metric.id]})); return i.assoc(existingMetrics, mergedMetric.id, mergedMetric); }; - return await updateData({dispatch, getState, requestStatePath, existingStatePath, putData}); + return await updateData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + dependentRequestStatePaths, + putData + }); + }; +}); + +const UPDATE_METRIC_IMPORTANT_FIELDS = "metabase/guide/UPDATE_METRIC_IMPORTANT_FIELDS"; +export const updateMetricImportantFields = createThunkAction(UPDATE_METRIC_IMPORTANT_FIELDS, function(metricId, importantFieldIds) { + return async (dispatch, getState) => { + const requestStatePath = ["reference", "guide", "metric_important_fields", metricId]; + const existingStatePath = requestStatePath; + const dependentRequestStatePaths = [['reference', 'guide']]; + const putData = async () => { + await MetricApi.update_important_fields({ metricId, important_field_ids: importantFieldIds }); + }; + + return await updateData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + dependentRequestStatePaths, + putData + }); }; }); @@ -121,7 +121,14 @@ export const fetchSegments = createThunkAction(FETCH_SEGMENTS, (reload = false) return segmentMap; }; - return await fetchData({dispatch, getState, requestStatePath, existingStatePath, getData, reload}); + return await fetchData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + getData, + reload + }); }; }); @@ -130,6 +137,7 @@ export const updateSegment = createThunkAction(UPDATE_SEGMENT, function(segment) return async (dispatch, getState) => { const requestStatePath = ["metadata", "segments", segment.id]; const existingStatePath = ["metadata", "segments"]; + const dependentRequestStatePaths = [['metadata', 'revisions', 'segment', segment.id]]; const putData = async () => { const updatedSegment = await SegmentApi.update(segment); const cleanSegment = cleanResource(updatedSegment); @@ -138,11 +146,17 @@ export const updateSegment = createThunkAction(UPDATE_SEGMENT, function(segment) const mergedSegment = {...existingSegment, ...cleanSegment}; - dispatch(clearRequestState({statePath: ['metadata', 'revisions', 'segment', segment.id]})); return i.assoc(existingSegments, mergedSegment.id, mergedSegment); }; - return await updateData({dispatch, getState, requestStatePath, existingStatePath, putData}); + return await updateData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + dependentRequestStatePaths, + putData + }); }; }); @@ -166,7 +180,14 @@ export const fetchDatabases = createThunkAction(FETCH_DATABASES, (reload = false return {...databaseMap, ...existingDatabases}; }; - return await fetchData({dispatch, getState, requestStatePath, existingStatePath, getData, reload}); + return await fetchData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + getData, + reload + }); }; }); @@ -182,7 +203,14 @@ export const fetchDatabaseMetadata = createThunkAction(FETCH_DATABASE_METADATA, return normalize(databaseMetadata, database).entities; }; - return await fetchData({dispatch, getState, requestStatePath, existingStatePath, getData, reload}); + return await fetchData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + getData, + reload + }); }; }); @@ -206,7 +234,13 @@ export const updateDatabase = createThunkAction(UPDATE_DATABASE, function(databa return i.assoc(existingDatabases, mergedDatabase.id, mergedDatabase); }; - return await updateData({dispatch, getState, requestStatePath, existingStatePath, putData}); + return await updateData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + putData + }); }; }); @@ -236,7 +270,13 @@ export const updateTable = createThunkAction(UPDATE_TABLE, function(table) { return i.assoc(existingTables, mergedTable.id, mergedTable); }; - return await updateData({dispatch, getState, requestStatePath, existingStatePath, putData}); + return await updateData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + putData + }); }; }); @@ -254,7 +294,14 @@ export const fetchTables = createThunkAction(FETCH_TABLES, (reload = false) => { return {...tableMap, ...existingTables}; }; - return await fetchData({dispatch, getState, requestStatePath, existingStatePath, getData, reload}); + return await fetchData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + getData, + reload + }); }; }); @@ -270,7 +317,14 @@ export const fetchTableMetadata = createThunkAction(FETCH_TABLE_METADATA, functi return normalize(tableMetadata, table).entities; }; - return await fetchData({dispatch, getState, requestStatePath, existingStatePath, getData, reload}); + return await fetchData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + getData, + reload + }); }; }); @@ -301,7 +355,13 @@ export const updateField = createThunkAction(UPDATE_FIELD, function(field) { return i.assoc(existingFields, mergedField.id, mergedField); }; - return await updateData({dispatch, getState, requestStatePath, existingStatePath, putData}); + return await updateData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + putData + }); }; }); @@ -324,7 +384,14 @@ export const fetchRevisions = createThunkAction(FETCH_REVISIONS, (type, id, relo return i.assocIn(existingRevisions, [type, id], revisionMap); }; - return await fetchData({dispatch, getState, requestStatePath, existingStatePath, getData, reload}); + return await fetchData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + getData, + reload + }); }; }); @@ -392,6 +459,18 @@ export const fetchSegmentRevisions = createThunkAction(FETCH_SEGMENT_REVISIONS, }; }); +const FETCH_DATABASES_WITH_METADATA = "metabase/metadata/FETCH_DATABASES_WITH_METADATA"; +export const fetchDatabasesWithMetadata = createThunkAction(FETCH_DATABASES_WITH_METADATA, (reload = false) => { + return async (dispatch, getState) => { + await dispatch(fetchDatabases()); + const databases = i.getIn(getState(), ['metadata', 'databases']); + await Promise.all( + Object.keys(databases) + .map(databaseId => dispatch(fetchDatabaseMetadata(databaseId))) + ); + }; +}); + export default combineReducers({ metrics, segments, diff --git a/frontend/src/metabase/reference/Reference.css b/frontend/src/metabase/reference/Reference.css index 812c2778f6baa2173f55c3b444bcb57d3cd4ee23..42975fa68eee68a584f6bbba71f762a162f08b68 100644 --- a/frontend/src/metabase/reference/Reference.css +++ b/frontend/src/metabase/reference/Reference.css @@ -1,4 +1,6 @@ :root { + --title-color: #606E7B; + --subtitle-color: #AAB7C3; --icon-width: calc(48px + 1rem); } @@ -16,6 +18,7 @@ composes: text-dark text-paragraph text-centered mt3 from "style"; } + :local(.columnHeader) { composes: flex flex-full from "style"; margin-left: var(--icon-width); @@ -35,5 +38,157 @@ } :local(.tableActualName) { - color: #AAB7C3; + color: var(--subtitle-color); +} + +:local(.guideLeftPadded) { + composes: flex full justify-center from "style"; +} + +:local(.guideLeftPadded)::before { + /*FIXME: not sure how to share this with other components + because we can't use composes here apparently. any workarounds?*/ + content: ''; + display: block; + flex: 0.3; + max-width: 250px; + margin-right: 50px; +} + +:local(.guideLeftPaddedBody) { + flex: 0.7; + max-width: 550px; +} + +:local(.guideWrapper) { + margin-bottom: 50px; +} + +:local(.guideTitle) { + composes: guideLeftPadded; + font-size: 24px; + margin-top: 50px; +} + +:local(.guideTitleBody) { + composes: full text-dark text-bold from "style"; + composes: guideLeftPaddedBody; +} + +:local(.guideSeeAll) { + composes: guideLeftPadded; + font-size: 18px; +} + +:local(.guideSeeAllBody) { + composes: flex full text-dark text-bold mt4 from "style"; + composes: guideLeftPaddedBody; +} + +:local(.guideSeeAllLink) { + composes: flex-full block no-decoration py1 border-top from "style"; +} + +:local(.guideContact) { + composes: mt4 from "style"; + composes: guideLeftPadded; + margin-bottom: 100px; +} + +:local(.guideContactBody) { + composes: full from "style"; + composes: guideLeftPaddedBody; + font-size: 16px; +} + +:local(.guideEditHeader) { + composes: full text-body my4 from "style"; + max-width: 550px; + color: var(--dark-color); +} + +:local(.guideEditHeaderTitle) { + composes: text-bold mb2 from "style"; + font-size: 24px; +} + +:local(.guideEditCards) { + composes: mt2 mb4 from "style"; +} + +:local(.guideEditCard) { + composes: input p4 from "style"; +} + + +:local(.guideEditLabel) { + composes: block text-bold mb2 from "style"; + font-size: 16px; + color: var(--title-color); +} + +:local(.guideEditHeaderDescription) { + font-size: 16px; +} + +:local(.guideEditTitle) { + composes: block text-body text-bold from "style"; + color: var(--title-color); + font-size: 16px; + margin-top: 50px; +} + +:local(.guideEditSubtitle) { + composes: text-body from "style"; + color: var(--grey-2); + font-size: 16px; + max-width: 700px; +} + +:local(.guideEditAddButton) { + composes: flex full my2 pl4 from "style"; + padding-right: 3.5rem; +} + +:local(.guideEditAddButton)::before { + content: ''; + display: block; + flex: 250; + max-width: 250px; + margin-right: 50px; +} + +:local(.guideEditAddButtonBody) { + flex: 550; + max-width: 550px; +} + +:local(.guideEditTextarea) { + composes: text-dark input p2 from "style"; + resize: none; + font-size: 16px; + width: 100%; + max-width: 850px; + min-height: 100px; +} + +:local(.guideEditContact) { + composes: flex from "style"; +} + +:local(.guideEditContactName) { + flex: 250; + max-width: 250px; + margin-right: 50px; +} + +:local(.guideEditContactEmail) { + flex: 550; + max-width: 550px; +} + +:local(.guideEditInput) { + composes: full text-dark input p2 from "style"; + font-size: 16px; + display: block; } diff --git a/frontend/src/metabase/components/Detail.css b/frontend/src/metabase/reference/components/Detail.css similarity index 94% rename from frontend/src/metabase/components/Detail.css rename to frontend/src/metabase/reference/components/Detail.css index 7aba4b3ffa0c824be020bfa3e650af63de4ac02c..2f87df61e2a3a8ac08a7fc8c8bd1c12fab9cb4ec 100644 --- a/frontend/src/metabase/components/Detail.css +++ b/frontend/src/metabase/reference/components/Detail.css @@ -27,6 +27,7 @@ :local(.detailSubtitle) { composes: text-dark mt2 text-paragraph from "style"; + white-space: pre-wrap; } :local(.detailSubtitleLight) { @@ -34,7 +35,7 @@ color: var(--subtitle-color); } -:local(.detailTextArea) { +:local(.detailTextarea) { composes: text-dark input p2 from "style"; resize: none; font-size: 16px; diff --git a/frontend/src/metabase/components/Detail.jsx b/frontend/src/metabase/reference/components/Detail.jsx similarity index 87% rename from frontend/src/metabase/components/Detail.jsx rename to frontend/src/metabase/reference/components/Detail.jsx index 9e9a6766fffac6473af49fb48bfa540774f67675..c6d76d55c70bd1ab5071e1f9cf371e00e069eca0 100644 --- a/frontend/src/metabase/components/Detail.jsx +++ b/frontend/src/metabase/reference/components/Detail.jsx @@ -18,9 +18,11 @@ const Detail = ({ name, description, placeholder, subtitleClass, url, icon, isEd <div className={cx(description ? S.detailSubtitle : S.detailSubtitleLight, { "mt1" : true })}> { isEditing ? <textarea - className={S.detailTextArea} + className={S.detailTextarea} placeholder={placeholder} {...field} + //FIXME: use initialValues from redux forms instead of default value + // to allow for reinitializing on cancel (see ReferenceGettingStartedGuide.jsx) defaultValue={description} /> : <span className={subtitleClass}>{description || placeholder || 'No description yet'}</span> diff --git a/frontend/src/metabase/reference/components/EditButton.css b/frontend/src/metabase/reference/components/EditButton.css new file mode 100644 index 0000000000000000000000000000000000000000..41a46282c02ee031aa5e54dfd958ae44657c77c8 --- /dev/null +++ b/frontend/src/metabase/reference/components/EditButton.css @@ -0,0 +1,15 @@ +:local(.editButton) { + composes: flex align-center text-dark p0 mx1 from "style"; + color: var(--primary-button-bg-color); + font-weight: normal; + font-size: 16px; +} + +:local(.editButton):hover { + color: color(var(--primary-button-border-color) shade(10%)); + transition: color .3s linear; +} + +:local(.editButtonBody) { + composes: flex align-center relative from "style"; +} \ No newline at end of file diff --git a/frontend/src/metabase/reference/components/EditButton.jsx b/frontend/src/metabase/reference/components/EditButton.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fe3a34779fcd078bd9b067f3beb973177fcdca79 --- /dev/null +++ b/frontend/src/metabase/reference/components/EditButton.jsx @@ -0,0 +1,29 @@ +import React, { Component, PropTypes } from "react"; +import cx from "classnames"; +import pure from "recompose/pure"; + +import S from "./EditButton.css"; + +import Icon from "metabase/components/Icon.jsx"; + +const EditButton = ({ + className, + startEditing +}) => + <button + className={cx("Button", "Button--borderless", S.editButton, className)} + type="button" + onClick={startEditing} + > + <div className={S.editButtonBody}> + <Icon name="pencil" size={16} /> + <span className="ml1">Edit</span> + </div> + </button> + +EditButton.propTypes = { + className: PropTypes.string, + startEditing: PropTypes.func.isRequired +}; + +export default pure(EditButton); diff --git a/frontend/src/metabase/reference/components/EditHeader.jsx b/frontend/src/metabase/reference/components/EditHeader.jsx index 8c72546bcecdcbc261eedba55279b412f677c69b..116e82143a8300db3619a6d93b04d01bfbe5b422 100644 --- a/frontend/src/metabase/reference/components/EditHeader.jsx +++ b/frontend/src/metabase/reference/components/EditHeader.jsx @@ -9,6 +9,7 @@ import RevisionMessageModal from "metabase/reference/components/RevisionMessageM const EditHeader = ({ hasRevisionHistory, endEditing, + reinitializeForm = () => undefined, submitting, onSubmit, revisionMessageFormField @@ -44,15 +45,19 @@ const EditHeader = ({ <button type="button" className={cx("Button", "Button--white", "Button--small", S.cancelButton)} - onClick={endEditing} + onClick={() => { + endEditing(); + reinitializeForm(); + }} > CANCEL </button> </div> </div>; EditHeader.propTypes = { - hasRevisionHistory: PropTypes.bool.isRequired, + hasRevisionHistory: PropTypes.bool, endEditing: PropTypes.func.isRequired, + reinitializeForm: PropTypes.func, submitting: PropTypes.bool.isRequired, onSubmit: PropTypes.func, revisionMessageFormField: PropTypes.object diff --git a/frontend/src/metabase/reference/components/FieldTypeDetail.jsx b/frontend/src/metabase/reference/components/FieldTypeDetail.jsx index cf454dffcfeee48e42617ba0307d78ceb2da2d90..c0ec97151b3d4ca836681c246e8350090e6a1917 100644 --- a/frontend/src/metabase/reference/components/FieldTypeDetail.jsx +++ b/frontend/src/metabase/reference/components/FieldTypeDetail.jsx @@ -9,7 +9,7 @@ import { isFK } from "metabase/lib/types"; import Select from "metabase/components/Select.jsx"; -import D from "metabase/components/Detail.css"; +import D from "metabase/reference/components/Detail.css"; const FieldTypeDetail = ({ field, diff --git a/frontend/src/metabase/reference/components/FieldsToGroupBy.jsx b/frontend/src/metabase/reference/components/FieldsToGroupBy.jsx index 8a7f409222f55db76ee0329a93f6efac787491af..e5b77572f10ff70486b11e751e53d45baf64bd48 100644 --- a/frontend/src/metabase/reference/components/FieldsToGroupBy.jsx +++ b/frontend/src/metabase/reference/components/FieldsToGroupBy.jsx @@ -3,7 +3,7 @@ import cx from "classnames"; import pure from "recompose/pure"; import S from "./UsefulQuestions.css"; -import D from "metabase/components/Detail.css"; +import D from "metabase/reference/components/Detail.css"; import L from "metabase/components/List.css"; import { @@ -13,7 +13,8 @@ import { import FieldToGroupBy from "metabase/reference/components/FieldToGroupBy.jsx"; const FieldsToGroupBy = ({ - table, + fields, + databaseId, metric, title, onChangeLocation @@ -24,8 +25,7 @@ const FieldsToGroupBy = ({ <span className={D.detailName}>{title}</span> </div> <div className={S.usefulQuestions}> - { table && table.fields_lookup && - Object.values(table.fields_lookup) + { fields && Object.values(fields) .map((field, index, fields) => <FieldToGroupBy key={field.id} @@ -34,14 +34,14 @@ const FieldsToGroupBy = ({ field={field} metric={metric} onClick={() => onChangeLocation(getQuestionUrl({ - dbId: table.db_id, - tableId: table.id, + dbId: databaseId, + tableId: field.table_id, fieldId: field.id, metricId: metric.id }))} secondaryOnClick={(event) => { event.stopPropagation(); - onChangeLocation(`/reference/databases/${table.db_id}/tables/${table.id}/fields/${field.id}`); + onChangeLocation(`/reference/databases/${databaseId}/tables/${field.table_id}/fields/${field.id}`); }} /> ) @@ -50,7 +50,8 @@ const FieldsToGroupBy = ({ </div> </div>; FieldsToGroupBy.propTypes = { - table: PropTypes.object.isRequired, + fields: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, metric: PropTypes.object.isRequired, title: PropTypes.string.isRequired, onChangeLocation: PropTypes.func.isRequired diff --git a/frontend/src/metabase/reference/components/GuideDetail.jsx b/frontend/src/metabase/reference/components/GuideDetail.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8a944134cdc86555ef39df7fd899af554ecc9d27 --- /dev/null +++ b/frontend/src/metabase/reference/components/GuideDetail.jsx @@ -0,0 +1,140 @@ +import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; +import pure from "recompose/pure"; +import cx from "classnames"; +import Icon from "metabase/components/Icon" + +import { + getQuestionUrl, + typeToBgClass, + typeToLinkClass, +} from "../utils"; + +const GuideDetail = ({ + entity = {}, + tables, + type, + exploreLinks, + detailLabelClasses +}) => { + const title = entity.display_name || entity.name; + const { caveats, points_of_interest } = entity; + const typeToLink = { + dashboard: `/dash/${entity.id}`, + metric: getQuestionUrl({ + dbId: tables[entity.table_id] && tables[entity.table_id].db_id, + tableId: entity.table_id, + metricId: entity.id + }), + segment: getQuestionUrl({ + dbId: tables[entity.table_id] && tables[entity.table_id].db_id, + tableId: entity.table_id, + segmentId: entity.id + }), + table: getQuestionUrl({ + dbId: entity.db_id, + tableId: entity.id + }) + }; + const link = typeToLink[type]; + const typeToLearnMoreLink = { + metric: `/reference/metrics/${entity.id}`, + segment: `/reference/segments/${entity.id}`, + table: `/reference/databases/${entity.db_id}/tables/${entity.id}` + }; + const learnMoreLink = typeToLearnMoreLink[type]; + + const linkClass = typeToLinkClass[type] + const linkHoverClass = `${typeToLinkClass[type]}-hover` + const bgClass = typeToBgClass[type] + const hasLearnMore = type === 'metric' || type === 'segment' || type === 'table'; + const interestingOrImportant = type === 'dashboard' ? 'important' : 'interesting'; + + return <div className="relative mt2 pb3"> + <div className="flex align-center"> + <div style={{ + width: 40, + height: 40, + left: -60 + }} + className={cx('absolute text-white flex align-center justify-center', bgClass)} + > + <Icon name={type === 'metric' ? 'ruler' : type} /> + </div> + { title && <ItemTitle link={link} title={title} linkColorClass={linkClass} linkHoverClass={linkHoverClass} /> } + </div> + <div className="mt2"> + <ContextHeading> + { `Why this ${type} is ${interestingOrImportant}` } + </ContextHeading> + + <ContextContent empty={!points_of_interest}> + {points_of_interest || `Nothing ${interestingOrImportant} yet`} + </ContextContent> + + <div className="mt2"> + <ContextHeading> + {`Things to be aware of about this ${type}`} + </ContextHeading> + + <ContextContent empty={!caveats}> + {caveats || 'Nothing to be aware of yet'} + </ContextContent> + </div> + + { exploreLinks && exploreLinks.length > 0 && [ + <div className="mt2"> + <ContextHeading key="detailLabel">Explore this metric</ContextHeading>, + <div key="detailLinks"> + <Link className="text-brand inline-block mr2 link text-bold" to={link}>View this metric</Link> + { exploreLinks.map(link => + <Link + className="inline-block text-bold text-brand mr2 link" + key={link.url} + to={link.url} + > + {`By ${link.name}`} + </Link> + )} + </div> + </div> + ]} + { hasLearnMore && + <Link + className={cx('block mt3 no-decoration text-underline-hover text-bold', linkClass)} + to={learnMoreLink} + > + Learn more + </Link> + } + </div> + </div>; +}; + +GuideDetail.propTypes = { + entity: PropTypes.object, + type: PropTypes.string, + exploreLinks: PropTypes.array +}; + +const ItemTitle = ({ title, link, linkColorClass, linkHoverClass }) => + <h2> + <Link + className={ cx(linkColorClass, linkHoverClass) } + style={{ textDecoration: 'none' }} + to={ link } + > + { title } + </Link> + </h2> + +const ContextHeading = ({ children }) => + <h3 className="mb1 text-grey-4">{ children }</h3> + +const ContextContent = ({ empty, children }) => + <p className={cx('m0 text-paragraph text-measure', { 'text-grey-3': empty })}> + { children } + </p> + + +export default pure(GuideDetail); diff --git a/frontend/src/metabase/reference/components/GuideDetailEditor.css b/frontend/src/metabase/reference/components/GuideDetailEditor.css new file mode 100644 index 0000000000000000000000000000000000000000..308f16906011cae13ab304f4e108ad74387e0cef --- /dev/null +++ b/frontend/src/metabase/reference/components/GuideDetailEditor.css @@ -0,0 +1,16 @@ +:local(.guideDetailEditor):last-child { + margin-bottom: 0; +} + +:local(.guideDetailEditorTextarea) { + composes: text-dark input p2 mb4 from "style"; + resize: none; + font-size: 16px; + width: 100%; + min-height: 100px; + background-color: unset; +} + +:local(.guideDetailEditorTextarea):last-child { + margin-bottom: 0; +} diff --git a/frontend/src/metabase/reference/components/GuideDetailEditor.jsx b/frontend/src/metabase/reference/components/GuideDetailEditor.jsx new file mode 100644 index 0000000000000000000000000000000000000000..86936f2993d7a1e1acf12815393293b07b416ab7 --- /dev/null +++ b/frontend/src/metabase/reference/components/GuideDetailEditor.jsx @@ -0,0 +1,223 @@ +import React, { Component, PropTypes } from "react"; +// FIXME: using pure seems to mess with redux form updates +// import pure from "recompose/pure"; +import cx from "classnames"; + +import S from "./GuideDetailEditor.css"; + +import Select from "metabase/components/Select.jsx"; +import Icon from "metabase/components/Icon.jsx"; +import DataSelector from "metabase/query_builder/DataSelector.jsx"; +import Tooltip from "metabase/components/Tooltip.jsx"; + +import { typeToBgClass } from "../utils.js"; + +const GuideDetailEditor = ({ + className, + type, + entities, + metadata = {}, + selectedIds = [], + selectedIdTypePairs = [], + formField, + removeField, + editLabelClasses +}) => { + const { + databases, + tables, + segments, + metrics, + fields, + metricImportantFields + } = metadata; + + const bgClass = typeToBgClass[type]; + const entityId = formField.id.value; + const disabled = formField.id.value === null || formField.id.value === undefined + const tableId = metrics && metrics[entityId] && metrics[entityId].table_id; + const tableFields = tables && tables[tableId] && tables[tableId].fields || []; + const fieldsByMetric = type === 'metric' ? + tableFields.map(fieldId => fields[fieldId]) : + []; + + const selectClasses = 'input h3 px2 py1' + + return <div className={cx('mb2 border-bottom pb4 text-measure', className)}> + <div className="relative mt2 flex align-center"> + <div + style={{ + width: 40, + height: 40, + left: -60 + }} + className={cx( + 'absolute text-white flex align-center justify-center', + bgClass + )} + > + <Icon name={type === 'metric' ? 'ruler' : type} /> + </div> + <div className="py2"> + { entities ? + <Select + className={selectClasses} + value={entities[formField.id.value]} + options={Object.values(entities)} + disabledOptionIds={selectedIds} + optionNameFn={option => option.display_name || option.name} + onChange={(entity) => { + //TODO: refactor into function + formField.id.onChange(entity.id); + formField.points_of_interest.onChange(entity.points_of_interest || ''); + formField.caveats.onChange(entity.caveats || ''); + if (type === 'metric') { + formField.important_fields.onChange(metricImportantFields[entity.id] && + metricImportantFields[entity.id] + .map(fieldId => fields[fieldId]) + ); + } + }} + placeholder={'Select...'} + /> : + <DataSelector + className={cx(selectClasses, 'inline-block', 'rounded', 'text-bold')} + triggerIconSize={12} + includeTables={true} + query={{ + query: { + source_table: formField.type.value === 'table' && + Number.parseInt(formField.id.value) + }, + database: ( + formField.type.value === 'table' && + tables[formField.id.value] && + tables[formField.id.value].db_id + ) || Number.parseInt(Object.keys(databases)[0]), + segment: formField.type.value === 'segment' && + Number.parseInt(formField.id.value) + }} + databases={ + Object.values(databases) + .map(database => ({ + ...database, + tables: database.tables.map(tableId => tables[tableId]) + })) + } + setDatabaseFn={() => null} + tables={Object.values(tables)} + disabledTableIds={selectedIdTypePairs + .filter(idTypePair => idTypePair[1] === 'table') + .map(idTypePair => idTypePair[0]) + } + setSourceTableFn={(tableId) => { + const table = tables[tableId]; + formField.id.onChange(table.id); + formField.type.onChange('table'); + formField.points_of_interest.onChange(table.points_of_interest || ''); + formField.caveats.onChange(table.caveats || ''); + }} + segments={Object.values(segments)} + disabledSegmentIds={selectedIdTypePairs + .filter(idTypePair => idTypePair[1] === 'segment') + .map(idTypePair => idTypePair[0]) + } + setSourceSegmentFn={(segmentId) => { + const segment = segments[segmentId]; + formField.id.onChange(segment.id); + formField.type.onChange('segment'); + formField.points_of_interest.onChange(segment.points_of_interest || ''); + formField.caveats.onChange(segment.caveats || ''); + }} + /> + } + </div> + <div className="ml-auto cursor-pointer text-grey-2"> + <Tooltip tooltip="Remove item"> + <Icon + name="close" + width={16} + height={16} + onClick={removeField} + /> + </Tooltip> + </div> + </div> + <div className="mt2 text-measure"> + <div className={cx('mb2', { 'disabled' : disabled })}> + <EditLabel> + { type === 'dashboard' ? + `Why is this dashboard the most important?` : + `What is useful or interesting about this ${type}?` + } + </EditLabel> + <textarea + className={S.guideDetailEditorTextarea} + placeholder="Write something helpful here" + {...formField.points_of_interest} + disabled={disabled} + /> + </div> + + <div className={cx('mb2', { 'disabled' : disabled })}> + <EditLabel> + { type === 'dashboard' ? + `Is there anything users of this dashboard should be aware of?` : + `Anything users should be aware of about this ${type}?` + } + </EditLabel> + <textarea + className={S.guideDetailEditorTextarea} + placeholder="Write something helpful here" + {...formField.caveats} + disabled={disabled} + /> + </div> + { type === 'metric' && + <div className={cx('mb2', { 'disabled' : disabled })}> + <EditLabel key="metricFieldsLabel"> + Which 2-3 fields do you usually group this metric by? + </EditLabel> + <Select + className={cx(selectClasses, 'inline-block')} + key="metricFieldsSelect" + triggerClasses={cx('px2', S.guideDetailEditorSelect)} + options={fieldsByMetric} + optionNameFn={option => option.display_name || option.name} + placeholder="Select..." + values={formField.important_fields.value || []} + disabledOptionIds={formField.important_fields.value && formField.important_fields.value.length === 3 ? + fieldsByMetric + .filter(field => !formField.important_fields.value.includes(field)) + .map(field => field.id) : + [] + } + onChange={(field) => { + const importantFields = formField.important_fields.value || []; + return importantFields.includes(field) ? + formField.important_fields.onChange(importantFields.filter(importantField => importantField !== field)) : + importantFields.length < 3 && formField.important_fields.onChange(importantFields.concat(field)); + }} + disabled={formField.id.value === null || formField.id.value === undefined} + /> + </div> + } + </div> + </div>; +}; + +const EditLabel = ({ children } ) => + <h3 className="mb1">{ children }</h3> + +GuideDetailEditor.propTypes = { + className: PropTypes.string, + type: PropTypes.string.isRequired, + entities: PropTypes.object, + metadata: PropTypes.object, + selectedIds: PropTypes.array, + selectedIdTypePairs: PropTypes.array, + formField: PropTypes.object.isRequired, + removeField: PropTypes.func.isRequired +}; + +export default GuideDetailEditor; diff --git a/frontend/src/metabase/reference/components/GuideEditSection.css b/frontend/src/metabase/reference/components/GuideEditSection.css new file mode 100644 index 0000000000000000000000000000000000000000..409bc25d2387c9c730c80f282a816948b8d78313 --- /dev/null +++ b/frontend/src/metabase/reference/components/GuideEditSection.css @@ -0,0 +1,21 @@ +:local(.guideEditSectionCollapsed) { + composes: flex flex-full align-center mt4 p3 input text-brand text-bold from "style"; + font-size: 16px; +} + +:local(.guideEditSectionDisabled) { + composes: text-grey-3 from "style"; +} + +:local(.guideEditSectionCollapsedIcon) { + composes: mr3 from "style"; +} + +:local(.guideEditSectionCollapsedTitle) { + composes: flex-full mr3 from "style"; +} + +:local(.guideEditSectionCollapsedLink) { + composes: text-brand no-decoration from "style"; + font-size: 14px; +} \ No newline at end of file diff --git a/frontend/src/metabase/reference/components/GuideEditSection.jsx b/frontend/src/metabase/reference/components/GuideEditSection.jsx new file mode 100644 index 0000000000000000000000000000000000000000..f8e416a928dd48be5191f6fa7068da770931c65e --- /dev/null +++ b/frontend/src/metabase/reference/components/GuideEditSection.jsx @@ -0,0 +1,66 @@ +import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; +import pure from "recompose/pure"; +import cx from "classnames"; + +import S from "./GuideEditSection.css"; + +import Icon from "metabase/components/Icon.jsx"; + +const GuideEditSection = ({ + children, + isCollapsed, + isDisabled, + showLink, + collapsedIcon, + collapsedTitle, + linkMessage, + link, + action, + expand +}) => isCollapsed ? + <div + className={cx( + 'text-measure', + S.guideEditSectionCollapsed, + { + 'cursor-pointer border-brand-hover': !isDisabled, + [S.guideEditSectionDisabled]: isDisabled + } + )} + onClick={!isDisabled && expand} + > + <Icon className={S.guideEditSectionCollapsedIcon} name={collapsedIcon} size={24} /> + <span className={S.guideEditSectionCollapsedTitle}>{collapsedTitle}</span> + {(showLink || isDisabled) && (link ? (link.startsWith('http') ? + <a + className={S.guideEditSectionCollapsedLink} + href={link} + target="_blank" + > + {linkMessage} + </a> : + <Link + className={S.guideEditSectionCollapsedLink} + to={link} + > + {linkMessage} + </Link> + ) : + action && + <a + className={S.guideEditSectionCollapsedLink} + onClick={action} + > + {linkMessage} + </a> + )} + </div> : + <div className={cx('my4', S.guideEditSection)}> + {children} + </div>; +GuideEditSection.propTypes = { + isCollapsed: PropTypes.bool.isRequired +}; + +export default pure(GuideEditSection); diff --git a/frontend/src/metabase/reference/components/GuideEmptyState.css b/frontend/src/metabase/reference/components/GuideEmptyState.css new file mode 100644 index 0000000000000000000000000000000000000000..b0aa96a73a4333821594e3997cf8afc3a08029b0 --- /dev/null +++ b/frontend/src/metabase/reference/components/GuideEmptyState.css @@ -0,0 +1,25 @@ +:local(.guideEmpty) { + composes: flex full justify-center from "style"; + padding-top: 75px; +} + +:local(.guideEmptyWrapper) { + composes: text-centered from "style"; + width: 550px; +} + +:local(.guideEmptyBody) { + composes: flex justify-center align-center mb4 from "style"; + flex-direction: column; +} + +:local(.guideEmptyMessage) { + composes: text-dark text-centered mt3 from "style"; + max-width: 450px; + font-size: 1.1em; + line-height: 1.457em; +} + +:local(.guideEmptyAction) { + composes: pt4 border-top from "style"; +} \ No newline at end of file diff --git a/frontend/src/metabase/reference/components/GuideEmptyState.jsx b/frontend/src/metabase/reference/components/GuideEmptyState.jsx new file mode 100644 index 0000000000000000000000000000000000000000..7aa664448bfe0943ce0ee6601e16b4eb918ef5dd --- /dev/null +++ b/frontend/src/metabase/reference/components/GuideEmptyState.jsx @@ -0,0 +1,31 @@ +import React, { Component, PropTypes } from "react"; +import pure from "recompose/pure"; + +import S from "./GuideEmptyState.css"; + +const GuideEmptyState = ({ + isSuperuser, + startEditing +}) => + <div className={S.guideEmpty}> + <div className={S.guideEmptyWrapper}> + <div className={S.guideEmptyBody}> + <img className="mb4" src={`/app/img/lightbulb.png`} height="200px" alt="Lightbulb" srcSet={`/app/img/lightbulb@2x.png 2x`} /> + <h1 className="text-bold text-dark">Understanding our data</h1> + <span className={S.guideEmptyMessage}>This guide lets you explore all the metrics, segments, and raw data that we currently have in Metabase. Select a section on the left to learn more about our data.</span> + </div> + { isSuperuser && + <div className={S.guideEmptyAction}> + <button className="Button Button--large Button--primary" onClick={startEditing}>Create a custom Getting Started guide</button> + </div> + } + </div> + </div>; +GuideEmptyState.propTypes = { + isSuperuser: PropTypes.bool.isRequired, + startEditing: PropTypes.func.isRequired +}; + +export default pure(GuideEmptyState); + + diff --git a/frontend/src/metabase/reference/components/GuideHeader.jsx b/frontend/src/metabase/reference/components/GuideHeader.jsx new file mode 100644 index 0000000000000000000000000000000000000000..28a37f070506960c1adeef68496bc3646248381f --- /dev/null +++ b/frontend/src/metabase/reference/components/GuideHeader.jsx @@ -0,0 +1,29 @@ +import React, { Component, PropTypes } from "react"; +import pure from "recompose/pure"; + +import EditButton from "metabase/reference/components/EditButton.jsx"; + +const GuideHeader = ({ + startEditing, + isSuperuser +}) => + <div> + <div className="wrapper wrapper--trim py4 my3"> + <div className="flex align-center"> + <h1 className="text-dark" style={{fontWeight: 700}}>Start here.</h1> + { isSuperuser && + <span className="ml-auto"> + <EditButton startEditing={startEditing}/> + </span> + } + </div> + <p className="text-paragraph" style={{maxWidth: 620}}>This is the perfect place to start if you’re new to your company’s data, or if you just want to check in on what’s going on.</p> + </div> + </div>; + +GuideHeader.propTypes = { + startEditing: PropTypes.func.isRequired, + isSuperuser: PropTypes.bool +}; + +export default pure(GuideHeader); diff --git a/frontend/src/metabase/reference/components/MetricImportantFieldsDetail.jsx b/frontend/src/metabase/reference/components/MetricImportantFieldsDetail.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1fbf5afb469053cacad2a40fade6fabefaa5e3cd --- /dev/null +++ b/frontend/src/metabase/reference/components/MetricImportantFieldsDetail.jsx @@ -0,0 +1,70 @@ +import React, { Component, PropTypes } from "react"; +import cx from "classnames"; +import pure from "recompose/pure"; + +import FieldsToGroupBy from "metabase/reference/components/FieldsToGroupBy.jsx"; + +import Select from "metabase/components/Select.jsx"; + +import D from "metabase/reference/components/Detail.css"; + +const MetricImportantFieldsDetail = ({ + fields, + metric, + table, + allFields, + isEditing, + onChangeLocation, + formField +}) => isEditing ? + <div className={cx(D.detail)}> + <div className={D.detailBody}> + <div className={D.detailTitle}> + <span className={D.detailName}> + Which 2-3 fields do you usually group this metric by? + </span> + </div> + <div className={cx(D.detailSubtitle, { "mt1" : true })}> + <Select + key="metricFieldsSelect" + triggerClasses="input p1 block" + options={table.fields.map(fieldId => allFields[fieldId])} + optionNameFn={option => option.display_name || option.name} + placeholder="Select..." + values={formField.value || []} + disabledOptionIds={formField.value && formField.value.length === 3 ? + table.fields + .map(fieldId => allFields[fieldId]) + .filter(field => !formField.value.includes(field)) + .map(field => field.id) : + [] + } + onChange={(field) => { + const importantFields = formField.value || []; + return importantFields.includes(field) ? + formField.onChange(importantFields.filter(importantField => importantField !== field)) : + importantFields.length < 3 && formField.onChange(importantFields.concat(field)); + }} + /> + </div> + </div> + </div> : + fields ? + <FieldsToGroupBy + fields={fields} + databaseId={table.db_id} + metric={metric} + title={"Most useful fields to group this metric by"} + onChangeLocation={onChangeLocation} + /> : + null; +MetricImportantFieldsDetail.propTypes = { + fields: PropTypes.object, + metric: PropTypes.object.isRequired, + table: PropTypes.object.isRequired, + isEditing: PropTypes.bool.isRequired, + onChangeLocation: PropTypes.func.isRequired, + formField: PropTypes.object.isRequired +}; + +export default pure(MetricImportantFieldsDetail); diff --git a/frontend/src/metabase/reference/components/ReferenceHeader.css b/frontend/src/metabase/reference/components/ReferenceHeader.css index 05d27f0d2ad78b37b522a8f29c70816db142064c..8199cae6abc47e902e9731704a0ac3bb7e8838c0 100644 --- a/frontend/src/metabase/reference/components/ReferenceHeader.css +++ b/frontend/src/metabase/reference/components/ReferenceHeader.css @@ -4,7 +4,7 @@ } :local(.headerBody) { - composes: flex flex-full border-bottom from "style"; + composes: flex flex-full border-bottom text-dark text-bold from "style"; overflow: hidden; align-items: center; border-color: #EDF5FB; @@ -38,18 +38,6 @@ transition: color .3s linear; } -:local(.editButton) { - composes: text-dark p0 mx1 from "style"; - color: var(--primary-button-bg-color); - font-weight: normal; - font-size: 16px; -} - -:local(.editButton):hover { - color: color(var(--primary-button-border-color) shade(10%)); - transition: color .3s linear; -} - :local(.headerSchema) { composes: text-grey-2 absolute from "style"; left: var(--icon-width); diff --git a/frontend/src/metabase/reference/components/ReferenceHeader.jsx b/frontend/src/metabase/reference/components/ReferenceHeader.jsx index bc14089bfc99e472161645a65ab7ce71222dd17f..21a718a30e020e3748fb145455e567b326cf6a4e 100644 --- a/frontend/src/metabase/reference/components/ReferenceHeader.jsx +++ b/frontend/src/metabase/reference/components/ReferenceHeader.jsx @@ -5,10 +5,13 @@ import pure from "recompose/pure"; import S from "./ReferenceHeader.css"; import L from "metabase/components/List.css"; +import E from "metabase/reference/components/EditButton.css"; import IconBorder from "metabase/components/IconBorder.jsx"; import Icon from "metabase/components/Icon.jsx"; import Ellipsified from "metabase/components/Ellipsified.jsx"; +import EditButton from "metabase/reference/components/EditButton.jsx"; + const ReferenceHeader = ({ entity = {}, @@ -76,14 +79,14 @@ const ReferenceHeader = ({ } </Ellipsified>, section.headerLink && - <div key="2" className={cx("flex-full", L.headerButton)}> + <div key="2" className={cx("flex-full", S.headerButton)}> <Link to={section.headerLink} - className={cx("Button", "Button--borderless", S.editButton)} + className={cx("Button", "Button--borderless", "ml3", E.editButton)} data-metabase-event={`Data Reference;Entity -> QB click;${section.type}`} > <div className="flex align-center relative"> - <span className="mr1">See this {section.type}</span> + <span className="mr1 flex-no-shrink">See this {section.type}</span> <Icon name="chevronright" size={16} /> </div> </Link> @@ -91,17 +94,7 @@ const ReferenceHeader = ({ ] } { user && user.is_superuser && !isEditing && - <div className={L.headerButton}> - <a - onClick={startEditing} - className={cx("Button", "Button--borderless", S.editButton)} - > - <div className="flex align-center relative"> - <Icon name="pencil" size={16} /> - <span className="ml1">Edit</span> - </div> - </a> - </div> + <EditButton className="ml1" startEditing={startEditing} /> } </div> </div> diff --git a/frontend/src/metabase/reference/components/UsefulQuestions.jsx b/frontend/src/metabase/reference/components/UsefulQuestions.jsx index 7e996cf40106d0ce9c25b34b9f66dcd70608259a..0c7e49a463a77860fca9fb1122de35f00f27b1b2 100644 --- a/frontend/src/metabase/reference/components/UsefulQuestions.jsx +++ b/frontend/src/metabase/reference/components/UsefulQuestions.jsx @@ -3,7 +3,7 @@ import cx from "classnames"; import pure from "recompose/pure"; import S from "./UsefulQuestions.css"; -import D from "metabase/components/Detail.css"; +import D from "metabase/reference/components/Detail.css"; import L from "metabase/components/List.css"; import QueryButton from "metabase/components/QueryButton.jsx"; diff --git a/frontend/src/metabase/reference/containers/ReferenceApp.jsx b/frontend/src/metabase/reference/containers/ReferenceApp.jsx index b214c74d7432f1b44d739ea837e6552102f0a6ed..8152a108017d2a82d0fa8c23d2578703c8dc46ad 100644 --- a/frontend/src/metabase/reference/containers/ReferenceApp.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceApp.jsx @@ -26,6 +26,10 @@ import { selectSection as fetchQuestions } from 'metabase/questions/questions'; +import { + fetchDashboards +} from 'metabase/dashboard/dashboard'; + const mapStateToProps = (state, props) => ({ sectionId: getSectionId(state, props), databaseId: getDatabaseId(state, props), @@ -37,6 +41,7 @@ const mapStateToProps = (state, props) => ({ const mapDispatchToProps = { fetchQuestions, + fetchDashboards, ...metadataActions, ...actions }; @@ -73,10 +78,16 @@ export default class ReferenceApp extends Component { render() { const { children, + section, sections, breadcrumbs, isEditing } = this.props; + + if (section.sidebar === false) { + return children; + } + return ( <SidebarLayout className="flex-full relative" @@ -85,6 +96,6 @@ export default class ReferenceApp extends Component { > {children} </SidebarLayout> - ) + ); } } diff --git a/frontend/src/metabase/reference/containers/ReferenceEntity.jsx b/frontend/src/metabase/reference/containers/ReferenceEntity.jsx index bf4622a03d5dd355e6f962ffd5f1c038f156658d..908a1fe84e316d6c6251da5e1620d491fc263912 100644 --- a/frontend/src/metabase/reference/containers/ReferenceEntity.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceEntity.jsx @@ -8,15 +8,16 @@ import { push } from "react-router-redux"; import S from "metabase/reference/Reference.css"; import List from "metabase/components/List.jsx"; -import Detail from "metabase/components/Detail.jsx"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; import EditHeader from "metabase/reference/components/EditHeader.jsx"; import ReferenceHeader from "metabase/reference/components/ReferenceHeader.jsx"; +import Detail from "metabase/reference/components/Detail.jsx"; import FieldTypeDetail from "metabase/reference/components/FieldTypeDetail.jsx"; import UsefulQuestions from "metabase/reference/components/UsefulQuestions.jsx"; import FieldsToGroupBy from "metabase/reference/components/FieldsToGroupBy.jsx"; import Formula from "metabase/reference/components/Formula.jsx"; +import MetricImportantFieldsDetail from "metabase/reference/components/MetricImportantFieldsDetail.jsx"; import { tryUpdateData @@ -26,6 +27,8 @@ import { getSection, getData, getTable, + getFields, + getGuide, getError, getLoading, getUser, @@ -41,22 +44,39 @@ import { import * as metadataActions from 'metabase/redux/metadata'; import * as actions from 'metabase/reference/reference'; -const mapStateToProps = (state, props) => ({ - section: getSection(state, props), - entity: getData(state, props) || {}, - table: getTable(state, props), - loading: getLoading(state, props), - // naming this 'error' will conflict with redux form - loadingError: getError(state, props), - user: getUser(state, props), - foreignKeys: getForeignKeys(state, props), - isEditing: getIsEditing(state, props), - hasSingleSchema: getHasSingleSchema(state, props), - hasQuestions: getHasQuestions(state, props), - hasDisplayName: getHasDisplayName(state, props), - isFormulaExpanded: getIsFormulaExpanded(state, props), - hasRevisionHistory: getHasRevisionHistory(state, props) -}); +const mapStateToProps = (state, props) => { + const entity = getData(state, props) || {}; + const guide = getGuide(state, props); + const fields = getFields(state, props); + + const initialValues = { + important_fields: guide && guide.metric_important_fields && + guide.metric_important_fields[entity.id] && + guide.metric_important_fields[entity.id] + .map(fieldId => fields[fieldId]) || + [] + }; + + return { + section: getSection(state, props), + entity, + table: getTable(state, props), + metadataFields: fields, + guide, + loading: getLoading(state, props), + // naming this 'error' will conflict with redux form + loadingError: getError(state, props), + user: getUser(state, props), + foreignKeys: getForeignKeys(state, props), + isEditing: getIsEditing(state, props), + hasSingleSchema: getHasSingleSchema(state, props), + hasQuestions: getHasQuestions(state, props), + hasDisplayName: getHasDisplayName(state, props), + isFormulaExpanded: getIsFormulaExpanded(state, props), + hasRevisionHistory: getHasRevisionHistory(state, props), + initialValues, + } +}; const mapDispatchToProps = { ...metadataActions, @@ -72,7 +92,7 @@ const validate = (values, props) => props.hasRevisionHistory ? @connect(mapStateToProps, mapDispatchToProps) @reduxForm({ form: 'details', - fields: ['name', 'display_name', 'description', 'revision_message', 'points_of_interest', 'caveats', 'how_is_this_calculated', 'special_type', 'fk_target_field_id'], + fields: ['name', 'display_name', 'description', 'revision_message', 'points_of_interest', 'caveats', 'how_is_this_calculated', 'special_type', 'fk_target_field_id', 'important_fields'], validate }) export default class ReferenceEntity extends Component { @@ -80,6 +100,8 @@ export default class ReferenceEntity extends Component { style: PropTypes.object.isRequired, entity: PropTypes.object.isRequired, table: PropTypes.object, + metadataFields: PropTypes.object, + guide: PropTypes.object, user: PropTypes.object.isRequired, foreignKeys: PropTypes.object, isEditing: PropTypes.bool, @@ -108,11 +130,13 @@ export default class ReferenceEntity extends Component { render() { const { - fields: { name, display_name, description, revision_message, points_of_interest, caveats, how_is_this_calculated, special_type, fk_target_field_id }, + fields: { name, display_name, description, revision_message, points_of_interest, caveats, how_is_this_calculated, special_type, fk_target_field_id, important_fields }, style, section, entity, table, + metadataFields, + guide, loadingError, loading, user, @@ -128,6 +152,7 @@ export default class ReferenceEntity extends Component { isFormulaExpanded, hasRevisionHistory, handleSubmit, + resetForm, submitting, onChangeLocation } = this.props; @@ -145,6 +170,7 @@ export default class ReferenceEntity extends Component { hasRevisionHistory={hasRevisionHistory} onSubmit={onSubmit} endEditing={endEditing} + reinitializeForm={resetForm} submitting={submitting} revisionMessageFormField={revision_message} /> @@ -255,12 +281,39 @@ export default class ReferenceEntity extends Component { <UsefulQuestions questions={section.questions} /> </li> } + { section.type === 'metric' && + <li className="relative"> + <MetricImportantFieldsDetail + fields={guide && guide.metric_important_fields[entity.id] && + Object.values(guide.metric_important_fields[entity.id]) + .map(fieldId => metadataFields[fieldId]) + .reduce((map, field) => ({ ...map, [field.id]: field }), {}) + } + table={table} + allFields={metadataFields} + metric={entity} + onChangeLocation={onChangeLocation} + isEditing={isEditing} + formField={important_fields} + /> + </li> + } { section.type === 'metric' && !isEditing && <li className="relative"> <FieldsToGroupBy - table={table} + fields={table.fields + .filter(fieldId => !guide || !guide.metric_important_fields[entity.id] || + !guide.metric_important_fields[entity.id].includes(fieldId) + ) + .map(fieldId => metadataFields[fieldId]) + .reduce((map, field) => ({ ...map, [field.id]: field }), {}) + } + databaseId={table.db_id} metric={entity} - title={"Fields you can group this metric by"} + title={ guide && guide.metric_important_fields[entity.id] ? + "Other fields you can group this metric by" : + "Fields you can group this metric by" + } onChangeLocation={onChangeLocation} /> </li> diff --git a/frontend/src/metabase/reference/containers/ReferenceFieldsList.jsx b/frontend/src/metabase/reference/containers/ReferenceFieldsList.jsx index b6a7eaa65705a4cdf63bbdaaf4850878f7e64228..1f665afdf358e709ae94e72134f7aea221f2cf1a 100644 --- a/frontend/src/metabase/reference/containers/ReferenceFieldsList.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceFieldsList.jsx @@ -87,7 +87,8 @@ export default class ReferenceEntityList extends Component { section: PropTypes.object.isRequired, loading: PropTypes.bool, loadingError: PropTypes.object, - submitting: PropTypes.bool + submitting: PropTypes.bool, + resetForm: PropTypes.func }; render() { @@ -104,6 +105,7 @@ export default class ReferenceEntityList extends Component { hasRevisionHistory, startEditing, endEditing, + resetForm, handleSubmit, submitting } = this.props; @@ -117,6 +119,7 @@ export default class ReferenceEntityList extends Component { { isEditing && <EditHeader hasRevisionHistory={hasRevisionHistory} + reinitializeForm={resetForm} endEditing={endEditing} submitting={submitting} /> diff --git a/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx b/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx index 42d68f66c2d11b9838641784e70ff3b6ecbdb7ac..8e403c24d2c36ed76b38be359096a3294f712b4f 100644 --- a/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceGettingStartedGuide.jsx @@ -1,17 +1,594 @@ /* eslint "react/prop-types": "warn" */ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; +import { connect } from 'react-redux'; +import { push } from 'react-router-redux'; +import { reduxForm } from "redux-form"; +import i from "icepick"; -import S from "metabase/reference/Reference.css"; +import { + getQuestionUrl +} from '../utils'; -import { pure } from "recompose"; +import MetabaseAnalytics from "metabase/lib/analytics"; -const ReferenceGettingStartedGuide = () => - <div className={S.guideEmpty}> - <div className={S.guideEmptyBody}> - <img className="mb4" src={`/app/img/lightbulb.png`} height="200px" alt="Lightbulb" srcSet={`/app/img/lightbulb@2x.png 2x`} /> - <h1 className="text-bold text-dark">Understanding our data</h1> - <div className={S.guideEmptyMessage}>This guide lets you explore all the metrics, segments, and raw data that we currently have in Metabase. Select a section on the left to learn more about our data.</div> - </div> - </div> +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; +import CreateDashboardModal from 'metabase/components/CreateDashboardModal.jsx'; +import Modal from 'metabase/components/Modal.jsx'; -export default pure(ReferenceGettingStartedGuide); +import EditHeader from "metabase/reference/components/EditHeader.jsx"; +import GuideEmptyState from "metabase/reference/components/GuideEmptyState.jsx"; +import GuideHeader from "metabase/reference/components/GuideHeader.jsx"; +import GuideEditSection from "metabase/reference/components/GuideEditSection.jsx"; +import GuideDetail from "metabase/reference/components/GuideDetail.jsx"; +import GuideDetailEditor from "metabase/reference/components/GuideDetailEditor.jsx"; + +import * as metadataActions from 'metabase/redux/metadata'; +import * as actions from 'metabase/reference/reference'; +import { clearRequestState } from "metabase/redux/requests"; +import { + updateDashboard, + createDashboard +} from 'metabase/dashboard/dashboard'; + +import { + updateSetting +} from 'metabase/admin/settings/settings'; + +import S from "../components/GuideDetailEditor.css"; + +import { + getGuide, + getUser, + getDashboards, + getMetrics, + getSegments, + getTables, + getFields, + getDatabases, + getLoading, + getError, + getIsEditing, + getIsDashboardModalOpen +} from '../selectors'; + +import { + isGuideEmpty, + tryUpdateGuide +} from '../utils'; + +const mapStateToProps = (state, props) => { + const guide = getGuide(state, props); + const dashboards = getDashboards(state, props); + const metrics = getMetrics(state, props); + const segments = getSegments(state, props); + const tables = getTables(state, props); + const fields = getFields(state, props); + const databases = getDatabases(state, props); + + // redux-form populates fields with stale values after update + // if we dont specify nulls here + // could use a lot of refactoring + const initialValues = guide && { + things_to_know: guide.things_to_know || null, + contact: guide.contact || {name: null, email: null}, + most_important_dashboard: guide.most_important_dashboard !== null ? + dashboards[guide.most_important_dashboard] : + {}, + important_metrics: guide.important_metrics && guide.important_metrics.length > 0 ? + guide.important_metrics + .map(metricId => metrics[metricId] && i.assoc(metrics[metricId], 'important_fields', guide.metric_important_fields[metricId] && guide.metric_important_fields[metricId].map(fieldId => fields[fieldId]))) : + [], + important_segments_and_tables: + (guide.important_segments && guide.important_segments.length > 0) || + (guide.important_tables && guide.important_tables.length > 0) ? + guide.important_segments + .map(segmentId => segments[segmentId] && i.assoc(segments[segmentId], 'type', 'segment')) + .concat(guide.important_tables + .map(tableId => tables[tableId] && i.assoc(tables[tableId], 'type', 'table')) + ) : + [] + }; + + return { + guide, + user: getUser(state, props), + dashboards, + metrics, + segments, + tables, + databases, + // FIXME: avoids naming conflict, tried using the propNamespace option + // version but couldn't quite get it to work together with passing in + // dynamic initialValues + metadataFields: fields, + loading: getLoading(state, props), + // naming this 'error' will conflict with redux form + loadingError: getError(state, props), + isEditing: getIsEditing(state, props), + isDashboardModalOpen: getIsDashboardModalOpen(state, props), + // redux form doesn't pass this through to component + // need to use to reset form field arrays + initialValues: initialValues, + initialFormValues: initialValues + }; +}; + +const mapDispatchToProps = { + push, + updateDashboard, + createDashboard, + updateSetting, + clearRequestState, + ...metadataActions, + ...actions +}; + +@connect(mapStateToProps, mapDispatchToProps) +@reduxForm({ + form: 'guide', + fields: [ + 'things_to_know', + 'contact.name', + 'contact.email', + 'most_important_dashboard.id', + 'most_important_dashboard.caveats', + 'most_important_dashboard.points_of_interest', + 'important_metrics[].id', + 'important_metrics[].caveats', + 'important_metrics[].points_of_interest', + 'important_metrics[].important_fields', + 'important_segments_and_tables[].id', + 'important_segments_and_tables[].type', + 'important_segments_and_tables[].caveats', + 'important_segments_and_tables[].points_of_interest' + ] +}) +export default class ReferenceGettingStartedGuide extends Component { + static propTypes = { + fields: PropTypes.object, + style: PropTypes.object, + guide: PropTypes.object, + user: PropTypes.object, + dashboards: PropTypes.object, + metrics: PropTypes.object, + segments: PropTypes.object, + tables: PropTypes.object, + databases: PropTypes.object, + metadataFields: PropTypes.object, + loadingError: PropTypes.any, + loading: PropTypes.bool, + isEditing: PropTypes.bool, + startEditing: PropTypes.func, + endEditing: PropTypes.func, + handleSubmit: PropTypes.func, + submitting: PropTypes.bool, + initialFormValues: PropTypes.object, + initializeForm: PropTypes.func, + createDashboard: PropTypes.func, + isDashboardModalOpen: PropTypes.bool, + showDashboardModal: PropTypes.func, + hideDashboardModal: PropTypes.func, + push: PropTypes.func + }; + + render() { + const { + fields: { + things_to_know, + contact, + most_important_dashboard, + important_metrics, + important_segments_and_tables + }, + style, + guide, + user, + dashboards, + metrics, + segments, + tables, + databases, + metadataFields, + loadingError, + loading, + isEditing, + startEditing, + endEditing, + handleSubmit, + submitting, + initialFormValues, + initializeForm, + createDashboard, + isDashboardModalOpen, + showDashboardModal, + hideDashboardModal, + push + } = this.props; + + const onSubmit = handleSubmit(async (fields) => + await tryUpdateGuide(fields, this.props) + ); + + const getSelectedIds = fields => fields + .map(field => field.id.value) + .filter(id => id !== null); + + const getSelectedIdTypePairs = fields => fields + .map(field => [field.id.value, field.type.value]) + .filter(idTypePair => idTypePair[0] !== null); + + return ( + <form className="full relative py4" style={style} onSubmit={onSubmit}> + { isDashboardModalOpen && + <Modal> + <CreateDashboardModal + createDashboardFn={async (newDashboard) => { + try { + const action = await createDashboard(newDashboard, true); + push(`/dash/${action.payload.id}`); + } + catch(error) { + console.error(error); + } + + MetabaseAnalytics.trackEvent("Dashboard", "Create"); + }} + closeFn={hideDashboardModal} + /> + </Modal> + } + { isEditing && + <EditHeader + endEditing={endEditing} + // resetForm doesn't reset field arrays + reinitializeForm={() => initializeForm(initialFormValues)} + submitting={submitting} + /> + } + <LoadingAndErrorWrapper className="full" style={style} loading={!loadingError && loading} error={loadingError}> + { () => isEditing ? + <div className="wrapper wrapper--trim"> + <div className="mt4 py2"> + <h1 className="my3 text-dark"> + Help new Metabase users find their way around. + </h1> + <p className="text-paragraph text-measure"> + The Getting Started guide highlights the dashboard, + metrics, segments, and tables that matter most, + and informs your users of important things they + should know before digging into the data. + </p> + </div> + + <GuideEditSection + isCollapsed={most_important_dashboard.id.value === undefined} + isDisabled={!dashboards || Object.keys(dashboards).length === 0} + collapsedTitle="Is there an important dashboard for your team?" + collapsedIcon="dashboard" + linkMessage="Create a dashboard now" + action={showDashboardModal} + expand={() => most_important_dashboard.id.onChange(null)} + > + <div> + <SectionHeader> + What is your most important dashboard? + </SectionHeader> + <GuideDetailEditor + type="dashboard" + entities={dashboards} + selectedIds={[most_important_dashboard.id.value]} + formField={most_important_dashboard} + removeField={() => { + most_important_dashboard.id.onChange(null); + most_important_dashboard.points_of_interest.onChange(''); + most_important_dashboard.caveats.onChange(''); + }} + /> + </div> + </GuideEditSection> + + <GuideEditSection + isCollapsed={important_metrics.length === 0} + isDisabled={!metrics || Object.keys(metrics).length === 0} + collapsedTitle="Do you have any commonly referenced metrics?" + collapsedIcon="ruler" + linkMessage="Learn how to define a metric" + link="http://www.metabase.com/docs/latest/administration-guide/05-segments-and-metrics#creating-a-metric" + expand={() => important_metrics.addField({id: null, caveats: null, points_of_interest: null, important_fields: null})} + > + <div className="my2"> + <SectionHeader> + What are your 3-5 most commonly referenced metrics? + </SectionHeader> + <div> + { important_metrics.map((metricField, index, metricFields) => + <GuideDetailEditor + key={index} + type="metric" + metadata={{ + tables, + metrics, + fields: metadataFields, + metricImportantFields: guide.metric_important_fields + }} + entities={metrics} + formField={metricField} + selectedIds={getSelectedIds(metricFields)} + removeField={() => { + if (metricFields.length > 1) { + return metricFields.removeField(index); + } + metricField.id.onChange(null); + metricField.points_of_interest.onChange(''); + metricField.caveats.onChange(''); + metricField.important_fields.onChange(null); + }} + /> + )} + </div> + { important_metrics.length < 5 && + important_metrics.length < Object.keys(metrics).length && + <button + className="Button Button--primary Button--large" + type="button" + onClick={() => important_metrics.addField({id: null, caveats: null, points_of_interest: null})} + > + Add another metric + </button> + } + </div> + </GuideEditSection> + + <GuideEditSection + isCollapsed={important_segments_and_tables.length === 0} + isDisabled={(!segments || Object.keys(segments).length === 0) && (!tables || Object.keys(tables).length === 0)} + showLink={!segments || Object.keys(segments).length === 0} + collapsedTitle="Do you have any commonly referenced segments or tables?" + collapsedIcon="table2" + linkMessage="Learn how to create a segment" + link="http://www.metabase.com/docs/latest/administration-guide/05-segments-and-metrics#creating-a-segment" + expand={() => important_segments_and_tables.addField({id: null, type: null, caveats: null, points_of_interest: null})} + > + <div> + <h2 className="text-measure text-dark"> + What are 3-5 commonly referenced segments or tables + that would be useful for this audience? + </h2> + <div className="mb2"> + { important_segments_and_tables.map((segmentOrTableField, index, segmentOrTableFields) => + <GuideDetailEditor + key={index} + type="segment" + metadata={{ + databases, + tables, + segments + }} + formField={segmentOrTableField} + selectedIdTypePairs={getSelectedIdTypePairs(segmentOrTableFields)} + removeField={() => { + if (segmentOrTableFields.length > 1) { + return segmentOrTableFields.removeField(index); + } + segmentOrTableField.id.onChange(null); + segmentOrTableField.type.onChange(null); + segmentOrTableField.points_of_interest.onChange(''); + segmentOrTableField.caveats.onChange(''); + }} + /> + )} + </div> + { important_segments_and_tables.length < 5 && + important_segments_and_tables.length < Object.keys(tables).concat(Object.keys.segments).length && + <button + className="Button Button--primary Button--large" + type="button" + onClick={() => important_segments_and_tables.addField({id: null, type: null, caveats: null, points_of_interest: null})} + > + Add another segment or table + </button> + } + </div> + </GuideEditSection> + + <GuideEditSection + isCollapsed={things_to_know.value === null} + isDisabled={false} + collapsedTitle="Is there anything your users should understand or know before they start accessing the data?" + collapsedIcon="reference" + expand={() => things_to_know.onChange('')} + > + <div className="text-measure"> + <SectionHeader> + What should a user of this data know before they start + accessing it? + </SectionHeader> + <textarea + className={S.guideDetailEditorTextarea} + placeholder="E.g., expectations around data privacy and use, + common pitfalls or misunderstandings, information about + data warehouse performance, legal notices, etc." + {...things_to_know} + /> + </div> + </GuideEditSection> + + <GuideEditSection + isCollapsed={contact.name.value === null && contact.email.value === null} + isDisabled={false} + collapsedTitle="Is there someone your users could contact for help if they're confused about this guide?" + collapsedIcon="mail" + expand={() => { + contact.name.onChange(''); + contact.email.onChange(''); + }} + > + <div> + <SectionHeader> + Who should users contact for help if they're confused about this data? + </SectionHeader> + <div className="flex"> + <div className="flex-full"> + <h3 className="mb1">Name</h3> + <input + className="input text-paragraph" + placeholder="Julie McHelpfulson" + type="text" + {...contact.name} + /> + </div> + <div className="flex-full"> + <h3 className="mb1">Email address</h3> + <input + className="input text-paragraph" + placeholder="julie.mchelpfulson@acme.com" + type="text" + {...contact.email} + /> + </div> + </div> + </div> + </GuideEditSection> + </div> : + !guide || isGuideEmpty(guide) ? + <GuideEmptyState + isSuperuser={user && user.is_superuser} + startEditing={startEditing} + /> : + <div> + <GuideHeader + startEditing={startEditing} + isSuperuser={user && user.is_superuser} + /> + <div className="wrapper wrapper--trim"> + { guide.most_important_dashboard !== null && [ + <div className="my2"> + <SectionHeader key={'dashboardTitle'}> + Our most important dashboard + </SectionHeader> + <GuideDetail + key={'dashboardDetail'} + type="dashboard" + entity={dashboards[guide.most_important_dashboard]} + tables={tables} + /> + </div> + ]} + <div className="my4"> + { guide.important_metrics && guide.important_metrics.length > 0 && [ + <div className="my2"> + <SectionHeader key={'metricsTitle'}> + Numbers that we pay attention to + </SectionHeader> + { guide.important_metrics.map((metricId) => + <GuideDetail + key={metricId} + type="metric" + entity={metrics[metricId]} + tables={tables} + exploreLinks={guide.metric_important_fields[metricId] && + guide.metric_important_fields[metricId] + .map(fieldId => metadataFields[fieldId]) + .map(field => ({ + name: field.display_name || field.name, + url: getQuestionUrl({ + dbId: tables[field.table_id] && tables[field.table_id].db_id, + tableId: field.table_id, + fieldId: field.id, + metricId + }) + })) + } + /> + )} , + <div key={'metricsSeeAll'}> + <Link className="Button Button--primary" to={'/reference/metrics'}> + See all metrics + </Link> + </div> + </div> + ]} + </div> + + <div className="mt4"> + { ((guide.important_segments && guide.important_segments.length > 0) || + (guide.important_tables && guide.important_tables.length > 0)) && [ + <div className="mt2"> + <SectionHeader key={'segmentTitle'}> + Segments and tables + </SectionHeader> + { guide.important_segments.map((segmentId) => + <GuideDetail + key={segmentId} + type="segment" + entity={segments[segmentId]} + tables={tables} + /> + )} + { guide.important_tables.map((tableId) => + <GuideDetail + key={tableId} + type="table" + entity={tables[tableId]} + tables={tables} + /> + )} + </div>, + <div key={'segmentSeeAll'}> + <div> + <Link className="Button Button--purple mr2" to={'/reference/segments'}> + See all segments + </Link> + <Link className="text-purple text-bold no-decoration text-underline-hover" to={'/reference/databases'}> + See all tables + </Link> + </div> + </div> + ]} + </div> + + <div className="mt4"> + { guide.things_to_know && [ + <SectionHeader key={'thingsToKnowTitle'}> + Other things to know about our data + </SectionHeader>, + <p className="text-paragraph text-measure" key={'thingsToKnowDetails'}> + { guide.things_to_know || `Nothing to know yet`} + </p>, + <Link className="link text-bold" to={'/reference/databases'} key={'thingsToKnowSeeAll'}> + Explore our data + </Link> + ]} + </div> + + <div className="mt4"> + { guide.contact && (guide.contact.name || guide.contact.email) && [ + <SectionHeader key={'contactTitle'}> + Have questions? + </SectionHeader>, + <div className="mb4 pb4" key={'contactDetails'}> + { guide.contact.name && + <span className="text-dark mr3"> + {`Contact ${guide.contact.name}`} + </span> + } + { guide.contact.email && + <a className="text-brand text-bold no-decoration" href={`mailto:${guide.contact.email}`}> + {guide.contact.email} + </a> + } + </div> + ]} + </div> + </div> + </div> + } + </LoadingAndErrorWrapper> + </form> + ); + } +} + +const SectionHeader = ({ children }) => // eslint-disable-line react/prop-types + <h2 className="text-dark text-measure mb4">{children}</h2> diff --git a/frontend/src/metabase/reference/reference.js b/frontend/src/metabase/reference/reference.js index 8bddeae54dc70c29560a74540083a2cdd43b945f..6b9ddf567ba2e9d897fae392fd74bafd9ec2c407 100644 --- a/frontend/src/metabase/reference/reference.js +++ b/frontend/src/metabase/reference/reference.js @@ -1,7 +1,38 @@ -import { handleActions, createAction } from 'metabase/lib/redux'; +import i from 'icepick'; + +import { + handleActions, + createAction, + createThunkAction, + AngularResourceProxy, + cleanResource, + fetchData +} from 'metabase/lib/redux'; + import MetabaseAnalytics from 'metabase/lib/analytics'; -import i from 'icepick'; +const GettingStartedApi = new AngularResourceProxy("GettingStarted", ["get"]); + +const FETCH_GUIDE = "metabase/reference/FETCH_GUIDE"; +export const fetchGuide = createThunkAction(FETCH_GUIDE, (reload = false) => { + return async (dispatch, getState) => { + const requestStatePath = ["reference", 'guide']; + const existingStatePath = requestStatePath; + const getData = async () => { + const guide = await GettingStartedApi.get(); + return cleanResource(guide); + }; + + return await fetchData({ + dispatch, + getState, + requestStatePath, + existingStatePath, + getData, + reload + }); + }; +}); const SET_ERROR = "metabase/reference/SET_ERROR"; export const setError = createAction(SET_ERROR); @@ -31,13 +62,25 @@ export const expandFormula = createAction(EXPAND_FORMULA); const COLLAPSE_FORMULA = "metabase/reference/COLLAPSE_FORMULA"; export const collapseFormula = createAction(COLLAPSE_FORMULA); +//TODO: consider making an app-wide modal state reducer and related actions +const SHOW_DASHBOARD_MODAL = "metabase/reference/SHOW_DASHBOARD_MODAL"; +export const showDashboardModal = createAction(SHOW_DASHBOARD_MODAL); + +const HIDE_DASHBOARD_MODAL = "metabase/reference/HIDE_DASHBOARD_MODAL"; +export const hideDashboardModal = createAction(HIDE_DASHBOARD_MODAL); + + const initialState = { error: null, isLoading: false, isEditing: false, isFormulaExpanded: false, + isDashboardModalOpen: false }; export default handleActions({ + [FETCH_GUIDE]: { + next: (state, { payload }) => i.assoc(state, 'guide', payload) + }, [SET_ERROR]: { throw: (state, { payload }) => i.assoc(state, 'error', payload) }, @@ -61,5 +104,11 @@ export default handleActions({ }, [COLLAPSE_FORMULA]: { next: (state) => i.assoc(state, 'isFormulaExpanded', false) + }, + [SHOW_DASHBOARD_MODAL]: { + next: (state) => i.assoc(state, 'isDashboardModalOpen', true) + }, + [HIDE_DASHBOARD_MODAL]: { + next: (state) => i.assoc(state, 'isDashboardModalOpen', false) } }, initialState); diff --git a/frontend/src/metabase/reference/selectors.js b/frontend/src/metabase/reference/selectors.js index 15fb4bcb9cccb3f3ed6f34c1f44828e73363756e..c27ceca7ff13db4170b98536b2876eb071b97e20 100644 --- a/frontend/src/metabase/reference/selectors.js +++ b/frontend/src/metabase/reference/selectors.js @@ -2,6 +2,10 @@ import { createSelector } from 'reselect'; import i from "icepick"; import Query, { AggregationClause } from 'metabase/lib/query'; +import { + resourceListToMap +} from 'metabase/lib/redux'; + import { idsToObjectMap, buildBreadcrumbs, @@ -14,13 +18,21 @@ import { //TODO: refactor to use different container components for each section // initialize section metadata in there // may not be worthwhile due to the extra boilerplate required -// ideal solution is to pass metadata to each section through router +// try using a higher-order component to reduce boilerplate? const referenceSections = { [`/reference/guide`]: { id: `/reference/guide`, name: "Understanding our data", breadcrumb: "Guide", - icon: "reference" + fetch: { + fetchGuide: [], + fetchDashboards: [], + fetchMetrics: [], + fetchSegments: [], + fetchDatabasesWithMetadata: [] + }, + icon: "reference", + sidebar: false }, [`/reference/metrics`]: { id: `/reference/metrics`, @@ -83,7 +95,11 @@ const getMetricSections = (metric, table, user) => metric ? { update: 'updateMetric', type: 'metric', breadcrumb: `${metric.name}`, - fetch: {fetchMetricTable: [metric.id]}, + fetch: { + fetchMetricTable: [metric.id], + // currently the only way to fetch metrics important fields + fetchGuide: [] + }, get: 'getMetric', icon: "document", headerIcon: "ruler", @@ -414,21 +430,21 @@ export const getUser = (state, props) => state.currentUser; export const getSectionId = (state, props) => props.location.pathname; export const getMetricId = (state, props) => Number.parseInt(props.params.metricId); -const getMetrics = (state, props) => state.metadata.metrics; +export const getMetrics = (state, props) => state.metadata.metrics; export const getMetric = createSelector( [getMetricId, getMetrics], (metricId, metrics) => metrics[metricId] || { id: metricId } ); export const getSegmentId = (state, props) => Number.parseInt(props.params.segmentId); -const getSegments = (state, props) => state.metadata.segments; +export const getSegments = (state, props) => state.metadata.segments; export const getSegment = createSelector( [getSegmentId, getSegments], (segmentId, segments) => segments[segmentId] || { id: segmentId } ); export const getDatabaseId = (state, props) => Number.parseInt(props.params.databaseId); -const getDatabases = (state, props) => state.metadata.databases; +export const getDatabases = (state, props) => state.metadata.databases; const getDatabase = createSelector( [getDatabaseId, getDatabases], (databaseId, databases) => databases[databaseId] || { id: databaseId } @@ -459,7 +475,7 @@ export const getTable = createSelector( ); export const getFieldId = (state, props) => Number.parseInt(props.params.fieldId); -const getFields = (state, props) => state.metadata.fields; +export const getFields = (state, props) => state.metadata.fields; const getFieldsByTable = createSelector( [getTable, getFields], (table, fields) => table && table.fields ? idsToObjectMap(table.fields, fields) : {} @@ -659,4 +675,11 @@ export const getHasQuestions = createSelector( export const getIsEditing = (state, props) => state.reference.isEditing; -export const getIsFormulaExpanded = (state, props) => state.reference.isFormulaExpanded; \ No newline at end of file +export const getIsFormulaExpanded = (state, props) => state.reference.isFormulaExpanded; + +export const getGuide = (state, props) => state.reference.guide; + +export const getDashboards = (state, props) => state.dashboard.dashboardListing && + resourceListToMap(state.dashboard.dashboardListing); + +export const getIsDashboardModalOpen = (state, props) => state.reference.isDashboardModalOpen; diff --git a/frontend/src/metabase/reference/utils.js b/frontend/src/metabase/reference/utils.js index 9039aeea0fbe5ef40be88ef91acf6ed568dc7cba..392b7eb7755360e8f81488a593442c2f1b24df56 100644 --- a/frontend/src/metabase/reference/utils.js +++ b/frontend/src/metabase/reference/utils.js @@ -1,4 +1,5 @@ import i from "icepick"; +import _ from "underscore"; import { titleize, humanize } from "metabase/lib/formatting"; import { startNewCard, serializeCardForUrl } from "metabase/lib/card"; @@ -6,11 +7,20 @@ import { isPK } from "metabase/lib/types"; export const idsToObjectMap = (ids, objects) => ids .map(id => objects[id]) - .reduce((map, object) => Object.assign({}, map, {[object.id]: object}), {}); + .reduce((map, object) => ({ ...map, [object.id]: object }), {}); // recursive freezing done by i.assoc here is too expensive // hangs browser for large databases // .reduce((map, object) => i.assoc(map, object.id, object), {}); +const filterUntouchedFields = (fields, entity = {}) => Object.keys(fields) + .filter(key => + fields[key] !== undefined && + entity[key] !== fields[key] + ) + .reduce((map, key) => ({ ...map, [key]: fields[key] }), {}); + +const isEmptyObject = (object) => Object.keys(object).length === 0; + export const tryFetchData = async (props) => { const { section, @@ -45,7 +55,9 @@ export const tryFetchData = async (props) => { export const tryUpdateData = async (fields, props) => { const { entity, + guide, section, + updateMetricImportantFields, startLoading, endLoading, resetForm, @@ -53,18 +65,34 @@ export const tryUpdateData = async (fields, props) => { endEditing } = props; - const editedFields = Object.keys(fields) - .filter(key => fields[key] !== undefined) - .reduce((map, key) => i.assoc(map, key, fields[key]), {}); - const newEntity = {...entity, ...editedFields}; startLoading(); try { - await props[section.update](newEntity); + const editedFields = filterUntouchedFields(fields, entity); + if (!isEmptyObject(editedFields)) { + const newEntity = {...entity, ...editedFields}; + await props[section.update](newEntity); + + if (section.type === 'metric' && fields.important_fields) { + const importantFieldIds = fields.important_fields.map(field => field.id); + const existingImportantFieldIds = guide.metric_important_fields && guide.metric_important_fields[entity.id]; + + const areFieldIdsIdentitical = existingImportantFieldIds && + existingImportantFieldIds.length === importantFieldIds.length && + existingImportantFieldIds.every(id => importantFieldIds.includes(id)); + + console.log(areFieldIdsIdentitical); + if (!areFieldIdsIdentitical) { + await updateMetricImportantFields(entity.id, importantFieldIds); + tryFetchData(props); + } + } + } } catch(error) { setError(error); console.error(error); } + resetForm(); endLoading(); endEditing(); @@ -77,35 +105,235 @@ export const tryUpdateFields = async (formFields, props) => { startLoading, endLoading, endEditing, + resetForm, setError } = props; - const updatedFields = Object.keys(formFields) - .map(fieldId => ({ - field: entities[fieldId], - formField: Object.keys(formFields[fieldId]) - .filter(key => formFields[fieldId][key] !== undefined) - .reduce((map, key) => i - .assoc(map, key, formFields[fieldId][key]), {} - ) - })) - .filter(({field, formField}) => Object - .keys(formField).length !== 0 - ) - .map(({field, formField}) => ({...field, ...formField})); - startLoading(); try { + const updatedFields = Object.keys(formFields) + .map(fieldId => ({ + field: entities[fieldId], + formField: filterUntouchedFields(formFields[fieldId], entities[fieldId]) + })) + .filter(({field, formField}) => !isEmptyObject(formField)) + .map(({field, formField}) => ({...field, ...formField})); + await Promise.all(updatedFields.map(updateField)); } catch(error) { setError(error); console.error(error); } + + resetForm(); endLoading(); endEditing(); } +export const tryUpdateGuide = async (formFields, props) => { + const { + guide, + dashboards, + metrics, + segments, + tables, + startLoading, + endLoading, + endEditing, + setError, + resetForm, + updateDashboard, + updateMetric, + updateSegment, + updateTable, + updateMetricImportantFields, + updateSetting, + fetchGuide, + clearRequestState + } = props; + + startLoading(); + try { + const updateNewEntities = ({ + entities, + formFields, + updateEntity + }) => formFields.map(formField => { + if (!formField.id) { + return []; + } + + const editedEntity = filterUntouchedFields( + i.assoc(formField, 'show_in_getting_started', true), + entities[formField.id] + ); + + if (isEmptyObject(editedEntity)) { + return []; + } + + const newEntity = entities[formField.id]; + const updatedNewEntity = { + ...newEntity, + ...editedEntity + }; + + const updatingNewEntity = updateEntity(updatedNewEntity); + + return [updatingNewEntity]; + }); + + const updateOldEntities = ({ + newEntityIds, + oldEntityIds, + entities, + updateEntity + }) => oldEntityIds + .filter(oldEntityId => !newEntityIds.includes(oldEntityId)) + .map(oldEntityId => { + const oldEntity = entities[oldEntityId]; + + const updatedOldEntity = i.assoc( + oldEntity, + 'show_in_getting_started', + false + ); + + const updatingOldEntity = updateEntity(updatedOldEntity); + + return [updatingOldEntity]; + }); + //FIXME: necessary because revision_message is a mandatory field + // even though we don't actually keep track of changes to caveats/points_of_interest yet + const updateWithRevisionMessage = updateEntity => entity => updateEntity(i.assoc( + entity, + 'revision_message', + 'Updated in Getting Started guide.' + )); + + const updatingDashboards = updateNewEntities({ + entities: dashboards, + formFields: [formFields.most_important_dashboard], + updateEntity: updateDashboard + }) + .concat(updateOldEntities({ + newEntityIds: formFields.most_important_dashboard ? + [formFields.most_important_dashboard.id] : [], + oldEntityIds: guide.most_important_dashboard ? + [guide.most_important_dashboard] : + [], + entities: dashboards, + updateEntity: updateDashboard + })); + + const updatingMetrics = updateNewEntities({ + entities: metrics, + formFields: formFields.important_metrics, + updateEntity: updateWithRevisionMessage(updateMetric) + }) + .concat(updateOldEntities({ + newEntityIds: formFields.important_metrics + .map(formField => formField.id), + oldEntityIds: guide.important_metrics, + entities: metrics, + updateEntity: updateWithRevisionMessage(updateMetric) + })); + + const updatingMetricImportantFields = formFields.important_metrics + .map(metricFormField => { + if (!metricFormField.id || !metricFormField.important_fields) { + return []; + } + const importantFieldIds = metricFormField.important_fields + .map(field => field.id); + const existingImportantFieldIds = guide.metric_important_fields[metricFormField.id]; + + const areFieldIdsIdentitical = existingImportantFieldIds && + existingImportantFieldIds.length === importantFieldIds.length && + existingImportantFieldIds.every(id => importantFieldIds.includes(id)); + if (areFieldIdsIdentitical) { + return []; + } + + return [updateMetricImportantFields(metricFormField.id, importantFieldIds)]; + }); + + const segmentFields = formFields.important_segments_and_tables + .filter(field => field.type === 'segment'); + + const updatingSegments = updateNewEntities({ + entities: segments, + formFields: segmentFields, + updateEntity: updateWithRevisionMessage(updateSegment) + }) + .concat(updateOldEntities({ + newEntityIds: segmentFields + .map(formField => formField.id), + oldEntityIds: guide.important_segments, + entities: segments, + updateEntity: updateWithRevisionMessage(updateSegment) + })); + + const tableFields = formFields.important_segments_and_tables + .filter(field => field.type === 'table'); + + const updatingTables = updateNewEntities({ + entities: tables, + formFields: tableFields, + updateEntity: updateTable + }) + .concat(updateOldEntities({ + newEntityIds: tableFields + .map(formField => formField.id), + oldEntityIds: guide.important_tables, + entities: tables, + updateEntity: updateTable + })); + + const updatingThingsToKnow = guide.things_to_know !== formFields.things_to_know ? + [updateSetting({key: 'getting-started-things-to-know', value: formFields.things_to_know })] : + []; + + const updatingContactName = guide.contact && formFields.contact && + guide.contact.name !== formFields.contact.name ? + [updateSetting({key: 'getting-started-contact-name', value: formFields.contact.name })] : + []; + + const updatingContactEmail = guide.contact && formFields.contact && + guide.contact.email !== formFields.contact.email ? + [updateSetting({key: 'getting-started-contact-email', value: formFields.contact.email })] : + []; + + const updatingData = _.flatten([ + updatingDashboards, + updatingMetrics, + updatingMetricImportantFields, + updatingSegments, + updatingTables, + updatingThingsToKnow, + updatingContactName, + updatingContactEmail + ]); + + if (updatingData.length > 0) { + await Promise.all(updatingData); + + clearRequestState({statePath: ['reference', 'guide']}); + + await fetchGuide(); + } + } + catch(error) { + setError(error); + console.error(error); + } + + resetForm(); + endLoading(); + endEditing(); +}; + const getBreadcrumb = (section, index, sections) => index !== sections.length - 1 ? [section.breadcrumb, section.id] : [section.breadcrumb]; @@ -207,3 +435,33 @@ export const getQuestion = ({dbId, tableId, fieldId, metricId, segmentId, getCou }; export const getQuestionUrl = getQuestionArgs => `/q#${serializeCardForUrl(getQuestion(getQuestionArgs))}`; + +export const isGuideEmpty = ({ + things_to_know, + contact, + most_important_dashboard, + important_metrics, + important_segments, + important_tables +} = {}) => things_to_know ? false : + contact && contact.name ? false : + contact && contact.email ? false : + most_important_dashboard ? false : + important_metrics && important_metrics.length !== 0 ? false : + important_segments && important_segments.length !== 0 ? false : + important_tables && important_tables.length !== 0 ? false : + true; + +export const typeToLinkClass = { + dashboard: 'text-green', + metric: 'text-brand', + segment: 'text-purple', + table: 'text-purple' +}; + +export const typeToBgClass = { + dashboard: 'bg-green', + metric: 'bg-brand', + segment: 'bg-purple', + table: 'bg-purple' +}; diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index bf0a26557c01e5eeea180be4d975e8d954cc7ca3..7873b8873a7aa48e5e93981b6782bb04d6ebc4a1 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -436,6 +436,14 @@ CoreServices.factory('Metric', ['$resource', function($resource) { method: 'PUT', params: { metricId: '@id' } }, + update_important_fields: { + url: '/api/metric/:metricId/important_fields', + method: 'PUT', + params: { + metricId: '@metricId', + important_field_ids: '@important_field_ids' + } + }, delete: { method: 'DELETE', params: { metricId: '@metricId' } @@ -570,6 +578,15 @@ CoreServices.factory('Settings', ['$resource', function($resource) { }); }]); +CoreServices.factory('GettingStarted', ['$resource', function($resource) { + return $resource('/api/getting_started', {}, { + get: { + url: '/api/getting_started', + method: 'GET', + } + }); +}]); + CoreServices.factory('Setup', ['$resource', function($resource) { return $resource('/api/setup/', {}, { create: { diff --git a/frontend/test/unit/redux/metadata.spec.js b/frontend/test/unit/lib/redux.spec.js similarity index 98% rename from frontend/test/unit/redux/metadata.spec.js rename to frontend/test/unit/lib/redux.spec.js index 55d1cbdfcff9014995095d680a080373387f2624..723be7003d9532c4bbe7ab2465969fd5abac683e 100644 --- a/frontend/test/unit/redux/metadata.spec.js +++ b/frontend/test/unit/lib/redux.spec.js @@ -1,4 +1,7 @@ -import { fetchData, updateData } from 'metabase/redux/metadata'; +import { + fetchData, + updateData +} from 'metabase/lib/redux'; describe("Metadata", () => { const getDefaultArgs = ({ diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj index 01eb43653beaf7648db8f584ea5e0006ec7713dc..e7140b9ee81f0aef611ca46cb7caed54488b12b6 100644 --- a/src/metabase/api/dashboard.clj +++ b/src/metabase/api/dashboard.clj @@ -45,7 +45,7 @@ (defendpoint PUT "/:id" "Update a `Dashboard`." - [id :as {{:keys [description name parameters], :as dashboard} :body}] + [id :as {{:keys [description name parameters caveats points_of_interest show_in_getting_started], :as dashboard} :body}] {name [Required NonEmptyString] parameters [ArrayOfMaps]} (write-check Dashboard id) diff --git a/src/metabase/api/getting_started.clj b/src/metabase/api/getting_started.clj index fa7238a0fa3493c21bbba397f114a159b976a4e5..f8078f1121beccf803b5569b568e68467c4db009 100644 --- a/src/metabase/api/getting_started.clj +++ b/src/metabase/api/getting_started.clj @@ -1,5 +1,6 @@ (ns metabase.api.getting-started (:require [compojure.core :refer [GET]] + [medley.core :as m] [metabase.api.common :refer :all] [metabase.db :as db] [metabase.models.setting :refer [defsetting]])) @@ -20,17 +21,12 @@ {:things_to_know (getting-started-things-to-know) :contact {:name (getting-started-contact-name) :email (getting-started-contact-email)} - :most_important_dashboard (db/select-one 'Dashboard :show_in_getting_started true) - ;; TODO - Need to hydrate the `MetricImportantFields` for this - :important_metrics (db/select 'Metric :show_in_getting_started true, {:order-by [:name]}) - ;; TODO - should these come back combined or separate? - :important_tables (db/select 'Table :show_in_getting_started true, {:order-by [:name]}) - :important_segments (db/select 'Segment :show_in_getting_started true, {:order-by [:name]})}) - - -;; TODO - Endpoint for editing the settings above? Or just edit them the normal way via PUT /api/setting/:key ? -;; TODO - Endpoint for setting most_important_dashboard (?) Or just have people set it the normal way via PUT /api/dashboard/:id ? -;; If we keep the existing endpoint it might make sense to clear `show_in_getting_started` for other Dashboards whenever this is set -;; TODO - Endpoints for setting most important metrics / tables / segments ? Or just have people set them the normal way via PUT /api/.../:id ? + :most_important_dashboard (db/select-one-id 'Dashboard :show_in_getting_started true) + :important_metrics (map :id (db/select ['Metric :id] :show_in_getting_started true, {:order-by [:name]})) + :important_tables (map :id (db/select ['Table :id] :show_in_getting_started true, {:order-by [:name]})) + :important_segments (map :id (db/select ['Segment :id] :show_in_getting_started true, {:order-by [:name]})) + ;; A map of metric_id -> sequence of important field_ids + :metric_important_fields (m/map-vals (partial map :field_id) + (group-by :metric_id (db/select ['MetricImportantField :field_id :metric_id])))}) (define-routes) diff --git a/src/metabase/api/metric.clj b/src/metabase/api/metric.clj index 50de91e5c80085ab903b67d0049e23fbeff25898..8bfac88f3f2b93290f3def84ea7881614149c97a 100644 --- a/src/metabase/api/metric.clj +++ b/src/metabase/api/metric.clj @@ -1,6 +1,7 @@ (ns metabase.api.metric "/api/metric endpoints." (:require [clojure.data :as data] + [clojure.tools.logging :as log] [compojure.core :refer [defroutes GET PUT POST DELETE]] [metabase.api.common :refer :all] [metabase.db :as db] @@ -36,21 +37,22 @@ (defendpoint PUT "/:id" "Update a `Metric` with ID." - [id :as {{:keys [name description caveats points_of_interest how_is_this_calculated definition revision_message]} :body}] + [id :as {{:keys [name description caveats points_of_interest how_is_this_calculated show_in_getting_started definition revision_message]} :body}] {name [Required NonEmptyString] revision_message [Required NonEmptyString] definition [Required Dict]} (check-superuser) (check-404 (metric/exists? id)) (metric/update-metric! - {:id id - :name name - :description description - :caveats caveats - :points_of_interest points_of_interest - :how_is_this_calculated how_is_this_calculated - :definition definition - :revision_message revision_message} + {:id id + :name name + :description description + :caveats caveats + :points_of_interest points_of_interest + :how_is_this_calculated how_is_this_calculated + :show_in_getting_started show_in_getting_started + :definition definition + :revision_message revision_message} *current-user-id*)) (defendpoint PUT "/:id/important_fields" @@ -62,8 +64,9 @@ (check-404 (metric/exists? id)) (check (<= (count important_field_ids) 3) [400 "A Metric can have a maximum of 3 important fields."]) - (let [[fields-to-remove fields-to-add] (data/diff (set (db/select-field :field_id 'MetricImportantField :metric_id 1)) + (let [[fields-to-remove fields-to-add] (data/diff (set (db/select-field :field_id 'MetricImportantField :metric_id id)) (set important_field_ids))] + ;; delete old fields as needed (when (seq fields-to-remove) (db/delete! 'MetricImportantField {:metric_id id, :field_id [:in fields-to-remove]})) diff --git a/src/metabase/api/segment.clj b/src/metabase/api/segment.clj index 5493c222cf48e777da1f51a497fbfb3c2d8e5aa5..1d65841f802de7d11633f8bedd08400c69b97460 100644 --- a/src/metabase/api/segment.clj +++ b/src/metabase/api/segment.clj @@ -35,20 +35,21 @@ (defendpoint PUT "/:id" "Update a `Segment` with ID." - [id :as {{:keys [name description caveats points_of_interest definition revision_message]} :body}] + [id :as {{:keys [name description caveats points_of_interest show_in_getting_started definition revision_message]} :body}] {name [Required NonEmptyString] revision_message [Required NonEmptyString] definition [Required Dict]} (check-superuser) (check-404 (segment/exists? id)) (segment/update-segment! - {:id id - :name name - :description description - :caveats caveats - :points_of_interest points_of_interest - :definition definition - :revision_message revision_message} + {:id id + :name name + :description description + :caveats caveats + :points_of_interest points_of_interest + :show_in_getting_started show_in_getting_started + :definition definition + :revision_message revision_message} *current-user-id*)) diff --git a/src/metabase/api/table.clj b/src/metabase/api/table.clj index 56795215a27cc9d0d6f491e18e57dd338199d109..78aa357a31e5ec534c5389e70400860b6d8fd3f8 100644 --- a/src/metabase/api/table.clj +++ b/src/metabase/api/table.clj @@ -39,17 +39,18 @@ (defendpoint PUT "/:id" "Update `Table` with ID." - [id :as {{:keys [display_name entity_type visibility_type description caveats points_of_interest]} :body}] + [id :as {{:keys [display_name entity_type visibility_type description caveats points_of_interest show_in_getting_started]} :body}] {display_name NonEmptyString, entity_type TableEntityType, visibility_type TableVisibilityType} (write-check Table id) (check-500 (db/update-non-nil-keys! Table id - :display_name display_name - :caveats caveats - :points_of_interest points_of_interest - :entity_type entity_type - :description description)) + :display_name display_name + :caveats caveats + :points_of_interest points_of_interest + :show_in_getting_started show_in_getting_started + :entity_type entity_type + :description description)) (check-500 (db/update! Table id, :visibility_type visibility_type)) (Table id)) diff --git a/src/metabase/models/dashboard.clj b/src/metabase/models/dashboard.clj index 909d90a971531afb1833b876b3f57929854e1236..a4c9cf21b80162d18fb2a6a8948775c909881912 100644 --- a/src/metabase/models/dashboard.clj +++ b/src/metabase/models/dashboard.clj @@ -55,15 +55,18 @@ (defn update-dashboard! "Update a `Dashboard`" - [{:keys [id name description parameters], :as dashboard} user-id] + [{:keys [id name description parameters caveats points_of_interest show_in_getting_started], :as dashboard} user-id] {:pre [(map? dashboard) (integer? id) (u/maybe? u/sequence-of-maps? parameters) (integer? user-id)]} (db/update-non-nil-keys! Dashboard id - :description description - :name name - :parameters parameters) + :description description + :name name + :parameters parameters + :caveats caveats + :points_of_interest points_of_interest + :show_in_getting_started show_in_getting_started) (u/prog1 (Dashboard id) (events/publish-event :dashboard-update (assoc <> :actor_id user-id)))) diff --git a/src/metabase/models/metric.clj b/src/metabase/models/metric.clj index 920560402b1c76f016763fa4a70752bd27087ffa..7cfdc5f83d71ad8944b41307b5fcd8bdeaf30f5b 100644 --- a/src/metabase/models/metric.clj +++ b/src/metabase/models/metric.clj @@ -117,7 +117,7 @@ "Update an existing `Metric`. Returns the updated `Metric` or throws an Exception." - [{:keys [id name description caveats points_of_interest how_is_this_calculated definition revision_message]} user-id] + [{:keys [id name description caveats points_of_interest how_is_this_calculated show_in_getting_started definition revision_message]} user-id] {:pre [(integer? id) (string? name) (map? definition) @@ -125,12 +125,13 @@ (string? revision_message)]} ;; update the metric itself (db/update! Metric id - :name name - :description description - :caveats caveats - :points_of_interest points_of_interest - :how_is_this_calculated how_is_this_calculated - :definition definition) + :name name + :description description + :caveats caveats + :points_of_interest points_of_interest + :how_is_this_calculated how_is_this_calculated + :show_in_getting_started show_in_getting_started + :definition definition) (u/prog1 (retrieve-metric id) (events/publish-event :metric-update (assoc <> :actor_id user-id, :revision_message revision_message)))) diff --git a/src/metabase/models/segment.clj b/src/metabase/models/segment.clj index 4251c5506d605ec67dda7aebe6409813c3e92684..4c6a9c4335b393278ef3e2730dc290cb0209c5a8 100644 --- a/src/metabase/models/segment.clj +++ b/src/metabase/models/segment.clj @@ -99,7 +99,7 @@ "Update an existing `Segment`. Returns the updated `Segment` or throws an Exception." - [{:keys [id name description caveats points_of_interest definition revision_message]} user-id] + [{:keys [id name description caveats points_of_interest show_in_getting_started definition revision_message]} user-id] {:pre [(integer? id) (string? name) (map? definition) @@ -107,11 +107,12 @@ (string? revision_message)]} ;; update the segment itself (db/update! Segment id - :name name - :description description - :caveats caveats - :points_of_interest points_of_interest - :definition definition) + :name name + :description description + :caveats caveats + :points_of_interest points_of_interest + :show_in_getting_started show_in_getting_started + :definition definition) (u/prog1 (retrieve-segment id) (events/publish-event :segment-update (assoc <> :actor_id user-id, :revision_message revision_message))))