diff --git a/frontend/src/metabase/dashboard/components/AddSeriesModal.jsx b/frontend/src/metabase/dashboard/components/AddSeriesModal.jsx index 0fcd87d7457706b5f8452f6bc093a8c325e65ac6..7146845333f042541e3111ecb7949d84a5631e82 100644 --- a/frontend/src/metabase/dashboard/components/AddSeriesModal.jsx +++ b/frontend/src/metabase/dashboard/components/AddSeriesModal.jsx @@ -9,7 +9,7 @@ import CheckBox from "metabase/components/CheckBox.jsx"; import MetabaseAnalytics from "metabase/lib/analytics"; import Query from "metabase/lib/query"; -import visualizations from "metabase/visualizations"; +import { getVisualizationRaw } from "metabase/visualizations"; import _ from "underscore"; import cx from "classnames"; @@ -76,7 +76,7 @@ export default class AddSeriesModal extends Component { async onCardChange(card, e) { const { dashcard, dashcardData } = this.props; - let CardVisualization = visualizations.get(dashcard.card.display); + let { CardVisualization } = getVisualizationRaw([{ card: dashcard.card }]); try { if (e.target.checked) { if (getIn(dashcardData, [dashcard.id, card.id]) === undefined) { @@ -142,7 +142,7 @@ export default class AddSeriesModal extends Component { data: getIn(dashcardData, [dashcard.id, dashcard.card.id, "data"]) }; - const CardVisualization = visualizations.get(dashcard.card.display); + let { CardVisualization } = getVisualizationRaw([{ card: dashcard.card }]); return cards.filter(card => { try { @@ -152,7 +152,7 @@ export default class AddSeriesModal extends Component { } if (card.dataset_query.type === "query") { if (!CardVisualization.seriesAreCompatible(initialSeries, - { card: card, data: { cols: getQueryColumns(card, databases) } } + { card: card, data: { cols: getQueryColumns(card, databases), rows: [] } } )) { return false; } diff --git a/frontend/src/metabase/dashboard/components/DashCard.jsx b/frontend/src/metabase/dashboard/components/DashCard.jsx index c6a76f869be80bdb5513e76bc7834e36fa1b9f0f..6e91340b64f5e72a8ffaa0d9a74627e341ac44af 100644 --- a/frontend/src/metabase/dashboard/components/DashCard.jsx +++ b/frontend/src/metabase/dashboard/components/DashCard.jsx @@ -1,7 +1,7 @@ import React, { Component, PropTypes } from "react"; import ReactDOM from "react-dom"; -import visualizations from "metabase/visualizations"; +import visualizations, { getVisualizationRaw } from "metabase/visualizations"; import Visualization from "metabase/visualizations/components/Visualization.jsx"; import ModalWithTrigger from "metabase/components/ModalWithTrigger.jsx"; @@ -72,7 +72,11 @@ export default class DashCard extends Component { render() { const { dashcard, dashcardData, cardDurations, parameterValues, isEditing, isEditingParameter, onAddSeries, onRemove } = this.props; - const cards = [dashcard.card].concat(dashcard.series || []); + const mainCard = { + ...dashcard.card, + visualization_settings: { ...dashcard.card.visualization_settings, ...dashcard.visualization_settings } + }; + const cards = [mainCard].concat(dashcard.series || []); const series = cards .map(card => ({ ...getIn(dashcardData, [dashcard.id, card.id]), @@ -103,7 +107,6 @@ export default class DashCard extends Component { errorIcon = "warning"; } - const CardVisualization = visualizations.get(series[0].card.display); return ( <div className={"Card bordered rounded flex flex-column " + cx({ @@ -125,9 +128,9 @@ export default class DashCard extends Component { actionButtons={isEditing && !isEditingParameter ? <DashCardActionButtons series={series} - visualization={CardVisualization} onRemove={onRemove} onAddSeries={onAddSeries} + onUpdateVisualizationSettings={this.props.onUpdateVisualizationSettings} /> : undefined } onUpdateVisualizationSetting={this.props.onUpdateVisualizationSetting} @@ -138,13 +141,13 @@ export default class DashCard extends Component { } } -const DashCardActionButtons = ({ series, visualization, onRemove, onAddSeries, onUpdateVisualizationSettings }) => +const DashCardActionButtons = ({ series, onRemove, onAddSeries, onUpdateVisualizationSettings }) => <span className="DashCard-actions flex align-center"> - { visualization.supportsSeries && + { getVisualizationRaw(series).CardVisualization.supportsSeries && <AddSeriesButton series={series} onAddSeries={onAddSeries} /> } { onUpdateVisualizationSettings && - <ChartSettingsButton series={series} onChange={onUpdateVisualizationSettings} /> + <ChartSettingsButton series={series} onUpdateVisualizationSettings={onUpdateVisualizationSettings} /> } <RemoveButton onRemove={onRemove} /> </span> @@ -158,6 +161,7 @@ const ChartSettingsButton = ({ series, onUpdateVisualizationSettings }) => <ChartSettings series={series} onChange={onUpdateVisualizationSettings} + isDashboard /> </ModalWithTrigger> diff --git a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx index 9220f2841edcf3f7593f4c348c17341ee9583e82..5d4fed5800a9ced30556adb81e884b7b199557ea 100644 --- a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx @@ -8,7 +8,7 @@ import Modal from "metabase/components/Modal.jsx"; import RemoveFromDashboardModal from "./RemoveFromDashboardModal.jsx"; import AddSeriesModal from "./AddSeriesModal.jsx"; -import visualizations from "metabase/visualizations"; +import { getVisualizationRaw } from "metabase/visualizations"; import MetabaseAnalytics from "metabase/lib/analytics"; import { @@ -90,9 +90,9 @@ export default class DashboardGrid extends Component { } getLayoutForDashCard(dashcard) { - let Viz = visualizations.get(dashcard.card.display); + let { CardVisualization } = getVisualizationRaw([{ card: dashcard.card }]); let initialSize = DEFAULT_CARD_SIZE; - let minSize = Viz.minSize || DEFAULT_CARD_SIZE; + let minSize = CardVisualization.minSize || DEFAULT_CARD_SIZE; return ({ i: String(dashcard.id), x: dashcard.col || 0, @@ -199,6 +199,13 @@ export default class DashboardGrid extends Component { }); } + onUpdateVisualizationSettings(dc, settings) { + this.props.setDashCardVisualizationSettings({ + id: dc.id, + settings: settings + }); + } + renderDashCard(dc, isMobile) { return ( <DashCard @@ -215,6 +222,7 @@ export default class DashboardGrid extends Component { onRemove={this.onDashCardRemove.bind(this, dc)} onAddSeries={this.onDashCardAddSeries.bind(this, dc)} onUpdateVisualizationSetting={this.onUpdateVisualizationSetting.bind(this, dc)} + onUpdateVisualizationSettings={this.onUpdateVisualizationSettings.bind(this, dc)} /> ) } diff --git a/frontend/src/metabase/dashboard/dashboard.js b/frontend/src/metabase/dashboard/dashboard.js index 222b3df30be73dbbc180b6d5530888a367d20663..705b5031aeb9fe29560267517d49dafc883b323e 100644 --- a/frontend/src/metabase/dashboard/dashboard.js +++ b/frontend/src/metabase/dashboard/dashboard.js @@ -43,6 +43,7 @@ export const ADD_CARD_TO_DASH = "metabase/dashboard/ADD_CARD_TO_DASH"; export const REMOVE_CARD_FROM_DASH = "metabase/dashboard/REMOVE_CARD_FROM_DASH"; export const SET_DASHCARD_ATTRIBUTES = "metabase/dashboard/SET_DASHCARD_ATTRIBUTES"; export const SET_DASHCARD_VISUALIZATION_SETTING = "metabase/dashboard/SET_DASHCARD_VISUALIZATION_SETTING"; +export const SET_DASHCARD_VISUALIZATION_SETTINGS = "metabase/dashboard/SET_DASHCARD_VISUALIZATION_SETTINGS"; export const UPDATE_DASHCARD_ID = "metabase/dashboard/UPDATE_DASHCARD_ID" export const SAVE_DASHCARD = "metabase/dashboard/SAVE_DASHCARD"; @@ -238,7 +239,15 @@ export const saveDashboard = createThunkAction(SAVE_DASHBOARD, function(dashId) let result = await DashboardApi.addcard({ dashId, cardId: dc.card_id }); dispatch(updateDashcardId(dc.id, result.id)); // mark isAdded because addcard doesn't record the position - return { ...result, col: dc.col, row: dc.row, sizeX: dc.sizeX, sizeY: dc.sizeY, series: dc.series, parameter_mappings: dc.parameter_mappings, isAdded: true } + return { + ...result, + col: dc.col, row: dc.row, + sizeX: dc.sizeX, sizeY: dc.sizeY, + series: dc.series, + parameter_mappings: dc.parameter_mappings, + visualization_settings: dc.visualization_settings, + isAdded: true + } } else { return dc; } @@ -257,9 +266,9 @@ export const saveDashboard = createThunkAction(SAVE_DASHBOARD, function(dashId) // reposition the cards if (_.some(updatedDashcards, (dc) => dc.isDirty || dc.isAdded)) { - let cards = updatedDashcards.map(({ id, card_id, row, col, sizeX, sizeY, series, parameter_mappings }) => + let cards = updatedDashcards.map(({ id, card_id, row, col, sizeX, sizeY, series, parameter_mappings, visualization_settings }) => ({ - id, card_id, row, col, sizeX, sizeY, series, + id, card_id, row, col, sizeX, sizeY, series, visualization_settings, parameter_mappings: parameter_mappings && parameter_mappings.filter(mapping => // filter out mappings for deleted paramters _.findWhere(dashboard.parameters, { id: mapping.parameter_id }) && @@ -342,6 +351,7 @@ export const revertToRevision = createThunkAction(REVERT_TO_REVISION, function({ }); export const setDashCardVisualizationSetting = createAction(SET_DASHCARD_VISUALIZATION_SETTING); +export const setDashCardVisualizationSettings = createAction(SET_DASHCARD_VISUALIZATION_SETTINGS); export const setEditingParameterId = createAction(SET_EDITING_PARAMETER_ID); export const setParameterMapping = createThunkAction(SET_PARAMETER_MAPPING, (parameter_id, dashcard_id, card_id, target) => @@ -411,8 +421,15 @@ const dashcards = handleActions({ [SET_DASHCARD_VISUALIZATION_SETTING]: { next: (state, { payload: { id, key, value } }) => i.chain(state) - .assocIn([id, "card", "visualization_settings", key], value) - .assocIn([id, "card", "isDirty"], true) + .assocIn([id, "visualization_settings", key], value) + .assocIn([id, "isDirty"], true) + .value() + }, + [SET_DASHCARD_VISUALIZATION_SETTINGS]: { + next: (state, { payload: { id, settings } }) => + i.chain(state) + .assocIn([id, "visualization_settings"], settings) + .assocIn([id, "isDirty"], true) .value() }, [ADD_CARD_TO_DASH]: (state, { payload: dashcard }) => ({ diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index 18d420b90638c9e68ece7c5e2c00fb2f3832d902..c5433cf20e8c1c64cf1220f1c6c3227f35638ad6 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -24,6 +24,7 @@ export var ICON_PATHS = { attrs: { scale: 2 } }, beaker: 'M4.31736354,31.1631075 C3.93810558,30.6054137 3.89343681,29.6635358 4.20559962,29.0817181 L11.806982,14.9140486 L11.8069821,10.5816524 L10.7015144,10.4653256 C10.0309495,10.394763 9.48734928,9.78799739 9.48734928,9.12166999 L9.48734928,7.34972895 C9.48734928,6.67821106 10.0368737,6.13383825 10.7172248,6.13383825 L21.8462005,6.13383825 C22.525442,6.13383825 23.0760761,6.68340155 23.0760761,7.34972895 L23.0760761,9.12166999 C23.0760761,9.79318788 22.5250158,10.3375607 21.856025,10.3375607 L20.9787023,10.3375607 L20.9787024,14.9281806 L28.77277,29.0827118 C29.0983515,29.6739888 29.0709073,30.6193105 28.7174156,31.1846409 L28.852457,30.9686726 C28.4963041,31.538259 27.6541076,32 26.9865771,32 L6.10749779,32 C5.43315365,32 4.58248747,31.5529687 4.19978245,30.9902061 L4.31736354,31.1631075 Z M15.5771418,17.6040443 C16.5170398,17.6040443 17.2789777,16.8377777 17.2789777,15.89254 C17.2789777,14.9473023 16.5170398,14.1810358 15.5771418,14.1810358 C14.6372438,14.1810358 13.8753059,14.9473023 13.8753059,15.89254 C13.8753059,16.8377777 14.6372438,17.6040443 15.5771418,17.6040443 Z M16.5496195,12.8974079 C17.8587633,12.8974079 18.9200339,11.830108 18.9200339,10.5135268 C18.9200339,9.1969457 17.8587633,8.1296458 16.5496195,8.1296458 C15.2404758,8.1296458 14.1792052,9.1969457 14.1792052,10.5135268 C14.1792052,11.830108 15.2404758,12.8974079 16.5496195,12.8974079 Z M5.71098553,30.2209651 L10.9595331,20.5151267 C10.9595331,20.5151267 12.6834557,21.2672852 14.3734184,21.2672852 C16.0633811,21.2672852 16.8198616,19.2872624 17.588452,18.6901539 C18.3570425,18.0930453 19.9467191,17.1113296 19.9467191,17.1113296 L27.0506095,30.1110325 L5.71098553,30.2209651 Z M13.6608671,4.37817079 C14.4114211,4.37817079 15.0198654,3.78121712 15.0198654,3.04483745 C15.0198654,2.30845779 14.4114211,1.71150412 13.6608671,1.71150412 C12.9103132,1.71150412 12.3018689,2.30845779 12.3018689,3.04483745 C12.3018689,3.78121712 12.9103132,4.37817079 13.6608671,4.37817079 Z M17.9214578,2.45333328 C18.6119674,2.45333328 19.1717361,1.90413592 19.1717361,1.22666664 C19.1717361,0.549197362 18.6119674,0 17.9214578,0 C17.2309481,0 16.6711794,0.549197362 16.6711794,1.22666664 C16.6711794,1.90413592 17.2309481,2.45333328 17.9214578,2.45333328 Z', + bubble: 'M24.8986904,17.6828137 C26.6918876,16.0317414 27.8153956,13.6644411 27.8153956,11.0347314 C27.8153956,6.04498702 23.7704085,2 18.7806642,2 C13.7909198,2 9.74593277,6.04498702 9.74593277,11.0347314 C9.74593277,15.6469684 13.2020126,19.4519947 17.6657078,20.0013457 C17.0007446,20.9830748 16.6123286,22.1673923 16.6123286,23.4424292 C16.6123286,26.8354553 19.3629198,29.5860465 22.755946,29.5860465 C26.1489721,29.5860465 28.8995633,26.8354553 28.8995633,23.4424292 C28.8995633,20.8030463 27.2351683,18.5524038 24.8986904,17.6828137 Z M12.1551945,28.7428049 C13.485793,28.7428049 14.5644562,27.6641417 14.5644562,26.3335432 C14.5644562,25.0029447 13.485793,23.9242815 12.1551945,23.9242815 C10.824596,23.9242815 9.74593277,25.0029447 9.74593277,26.3335432 C9.74593277,27.6641417 10.824596,28.7428049 12.1551945,28.7428049 Z M6.91505027,23.3219661 C9.07727283,23.3219661 10.8301005,21.5691384 10.8301005,19.4069158 C10.8301005,17.2446933 9.07727283,15.4918655 6.91505027,15.4918655 C4.75282771,15.4918655 3,17.2446933 3,19.4069158 C3,21.5691384 4.75282771,23.3219661 6.91505027,23.3219661 Z', cards: 'M16.5,11 C16.1340991,11 15.7865579,10.9213927 15.4733425,10.7801443 L7.35245972,21.8211652 C7.7548404,22.264891 8,22.8538155 8,23.5 C8,24.8807119 6.88071187,26 5.5,26 C4.11928813,26 3,24.8807119 3,23.5 C3,22.1192881 4.11928813,21 5.5,21 C5.87370843,21 6.22826528,21.0819977 6.5466604,21.2289829 L14.6623495,10.1950233 C14.2511829,9.74948188 14,9.15407439 14,8.5 C14,7.11928813 15.1192881,6 16.5,6 C17.8807119,6 19,7.11928813 19,8.5 C19,8.96980737 18.8704088,9.4093471 18.6450228,9.78482291 L25.0405495,15.4699905 C25.4512188,15.1742245 25.9552632,15 26.5,15 C27.8807119,15 29,16.1192881 29,17.5 C29,18.8807119 27.8807119,20 26.5,20 C25.1192881,20 24,18.8807119 24,17.5 C24,17.0256697 24.1320984,16.5821926 24.3615134,16.2043506 L17.9697647,10.5225413 C17.5572341,10.8228405 17.0493059,11 16.5,11 Z M5.5,25 C6.32842712,25 7,24.3284271 7,23.5 C7,22.6715729 6.32842712,22 5.5,22 C4.67157288,22 4,22.6715729 4,23.5 C4,24.3284271 4.67157288,25 5.5,25 Z M26.5,19 C27.3284271,19 28,18.3284271 28,17.5 C28,16.6715729 27.3284271,16 26.5,16 C25.6715729,16 25,16.6715729 25,17.5 C25,18.3284271 25.6715729,19 26.5,19 Z M16.5,10 C17.3284271,10 18,9.32842712 18,8.5 C18,7.67157288 17.3284271,7 16.5,7 C15.6715729,7 15,7.67157288 15,8.5 C15,9.32842712 15.6715729,10 16.5,10 Z', calendar: { path: 'M21,2 L21,0 L18,0 L18,2 L6,2 L6,0 L3,0 L3,2 L2.99109042,2 C1.34177063,2 0,3.34314575 0,5 L0,6.99502651 L0,20.009947 C0,22.2157067 1.78640758,24 3.99005301,24 L20.009947,24 C22.2157067,24 24,22.2135924 24,20.009947 L24,6.99502651 L24,5 C24,3.34651712 22.6608432,2 21.0089096,2 L21,2 L21,2 Z M22,8 L22,20.009947 C22,21.1099173 21.1102431,22 20.009947,22 L3.99005301,22 C2.89008272,22 2,21.1102431 2,20.009947 L2,8 L22,8 L22,8 Z M6,12 L10,12 L10,16 L6,16 L6,12 Z', @@ -143,6 +144,7 @@ export var ICON_PATHS = { svg: '<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g id="console-icon" fill="currentcolor"><path d="M0,2.00010618 C0,0.895478039 0.890925393,0 1.99742191,0 L17.0025781,0 C18.1057238,0 19,0.890058413 19,2.00010618 L19,14.9998938 C19,16.104522 18.1090746,17 17.0025781,17 L1.99742191,17 C0.894276248,17 0,16.1099416 0,14.9998938 L0,2.00010618 Z M2,3 L17,3 L17,15 L2,15 L2,3 Z M3.031807,4 L3.031807,6.28741984 L5.12297107,8.37858392 L3,10.501555 L3,13.0687379 L7.67002201,8.39871591 L3.031807,4 Z M6,12 L15,12 L15,14 L6,14 L6,12 Z" id="Combined-Shape"></path></g></g>', attrs: { viewBox: '0 0 19 17' } }, + progress: 'M3,12.0085302 C3,10.8992496 3.89497885,10 4.99700466,10 L27.0029953,10 C28.1059106,10 29,10.9019504 29,12.0085302 L29,19.9914698 C29,21.1007504 28.1050211,22 27.0029953,22 L4.99700466,22 C3.89408944,22 3,21.0980496 3,19.9914698 L3,12.0085302 L3,12.0085302 Z M19.8571429,12 L26.1450862,12 C26.6961399,12 27.1428571,12.4530363 27.1428571,12.9970301 L27.1428571,19.0029699 C27.1428571,19.5536144 26.6871031,20 26.1450862,20 L19.8571429,20 L19.8571429,12 L19.8571429,12 Z', sync: 'M16 2 A14 14 0 0 0 2 16 A14 14 0 0 0 16 30 A14 14 0 0 0 26 26 L 23.25 23 A10 10 0 0 1 16 26 A10 10 0 0 1 6 16 A10 10 0 0 1 16 6 A10 10 0 0 1 23.25 9 L19 13 L30 13 L30 2 L26 6 A14 14 0 0 0 16 2', return:'M15.3040432,11.8500793 C22.1434689,13.0450349 27.291257,18.2496116 27.291257,24.4890512 C27.291257,25.7084278 27.0946472,26.8882798 26.7272246,28.0064033 L26.7272246,28.0064033 C25.214579,22.4825472 20.8068367,18.2141694 15.3040432,17.0604596 L15.3040432,25.1841972 L4.70874296,14.5888969 L15.3040432,3.99359668 L15.3040432,3.99359668 L15.3040432,11.8500793 Z', reference: { diff --git a/frontend/src/metabase/lib/query.js b/frontend/src/metabase/lib/query.js index cff87c107910d3aa5c8148a210a7006f19826faf..018d00dc2622c5e3a4b6a0f7512f7a9761c919a1 100644 --- a/frontend/src/metabase/lib/query.js +++ b/frontend/src/metabase/lib/query.js @@ -42,6 +42,29 @@ export function createQuery(type = "query", databaseId, tableId) { return dataset_query; } + +const METRIC_NAME_BY_AGGREGATION = { + "count": "count", + "cum_count": "count", + "sum": "sum", + "cum_sum": "sum", + "distinct": "count", + "avg": "avg", + "min": "min", + "max": "max", +} + +const METRIC_TYPE_BY_AGGREGATION = { + "count": TYPE.Integer, + "cum_count": TYPE.Integer, + "sum": TYPE.Float, + "cum_sum": TYPE.Float, + "distinct": TYPE.Integer, + "avg": TYPE.Float, + "min": TYPE.Float, + "max": TYPE.Float, +} + const mbqlCanonicalize = (a) => typeof a === "string" ? a.toLowerCase().replace(/_/g, "-") : a; const mbqlCompare = (a, b) => mbqlCanonicalize(a) === mbqlCanonicalize(b) @@ -719,13 +742,17 @@ var Query = { getQueryColumns(tableMetadata, query) { let columns = Query.getBreakouts(query).map(b => Query.getQueryColumn(tableMetadata, b)); - if (Query.getAggregationType(query) === "rows") { + const aggregation = Query.getAggregationType(query); + if (aggregation === "rows") { if (columns.length === 0) { return null; } } else { - // NOTE: incomplete (missing name etc), count/distinct are actually TYPE.Integer, but close enough for now - columns.push({ base_type: TYPE.Float, special_type: TYPE.Number }); + columns.push({ + name: METRIC_NAME_BY_AGGREGATION[aggregation], + base_type: METRIC_TYPE_BY_AGGREGATION[aggregation], + special_type: TYPE.Number + }); } return columns; } diff --git a/frontend/src/metabase/lib/visualization_settings.js b/frontend/src/metabase/lib/visualization_settings.js index 5e0d892074c5316f89a28dcb76e7253e8347d48e..6add83fc119f56f4f044680d65c921cf4d70b7c4 100644 --- a/frontend/src/metabase/lib/visualization_settings.js +++ b/frontend/src/metabase/lib/visualization_settings.js @@ -1,5 +1,4 @@ import _ from "underscore"; -import crossfilter from "crossfilter"; import MetabaseSettings from "metabase/lib/settings"; @@ -45,17 +44,8 @@ function columnsAreValid(colNames, data, filter = () => true) { , true); } -function getSeriesTitles([{ data: { rows, cols } }], vizSettings) { - const seriesDimension = vizSettings["graph.dimensions"][1]; - if (seriesDimension != null) { - let seriesIndex = _.findIndex(cols, (col) => col.name === seriesDimension); - return crossfilter(rows).dimension(d => d[seriesIndex]).group().all().map(v => v.key); - } else { - return vizSettings["graph.metrics"].map(name => { - let col = _.findWhere(cols, { name }); - return col && getFriendlyName(col); - }); - } +function getSeriesTitles(series, vizSettings) { + return series.map(s => s.card.name); } function getDefaultColumns(series) { @@ -138,11 +128,20 @@ import { normal } from "metabase/lib/colors"; const isAnyField = () => true; const SETTINGS = { + "card.title": { + title: "Title", + widget: ChartSettingInput, + getDefault: (series) => series.length === 1 ? series[0].card.name : null, + dashboard: true, + useRawSeries: true + }, "graph._dimension_filter": { - getDefault: ([{ card }]) => card.display === "scatter" ? isAnyField : isDimension + getDefault: ([{ card }]) => card.display === "scatter" ? isAnyField : isDimension, + useRawSeries: true }, "graph._metric_filter": { - getDefault: ([{ card }]) => card.display === "scatter" ? isNumeric : isMetric + getDefault: ([{ card }]) => card.display === "scatter" ? isNumeric : isMetric, + useRawSeries: true }, "graph.dimensions": { section: "Data", @@ -163,7 +162,9 @@ const SETTINGS = { }; }, readDependencies: ["graph._dimension_filter", "graph._metric_filter"], - writeDependencies: ["graph.metrics"] + writeDependencies: ["graph.metrics"], + dashboard: false, + useRawSeries: true }, "graph.metrics": { section: "Data", @@ -184,7 +185,9 @@ const SETTINGS = { }; }, readDependencies: ["graph._dimension_filter", "graph._metric_filter"], - writeDependencies: ["graph.dimensions"] + writeDependencies: ["graph.dimensions"], + dashboard: false, + useRawSeries: true }, "scatter.bubble": { section: "Data", @@ -201,7 +204,9 @@ const SETTINGS = { onRemove: vizSettings["scatter.bubble"] ? () => onChange(null) : null }; }, - writeDependencies: ["graph.dimensions"] + writeDependencies: ["graph.dimensions"], + dashboard: false, + useRawSeries: true }, "line.interpolate": { section: "Display", @@ -310,7 +315,7 @@ const SETTINGS = { "graph.colors": { section: "Display", getTitle: ([{ card: { display } }]) => - capitalize(display === "scatter" ? "bubble" : display) + " Colors", + capitalize(display === "scatter" ? "bubble" : display) + " colors", widget: ChartSettingColorsPicker, readDependencies: ["graph.dimensions", "graph.metrics"], getDefault: ([{ card, data }], vizSettings) => { @@ -378,7 +383,8 @@ const SETTINGS = { section: "Axes", title: "Use a split y-axis when necessary", widget: ChartSettingToggle, - default: true + default: true, + getHidden: (series) => series.length < 2 }, "graph.x_axis.labels_enabled": { section: "Labels", @@ -390,7 +396,10 @@ const SETTINGS = { section: "Labels", title: "X-axis label", widget: ChartSettingInput, - getHidden: (series, vizSettings) => vizSettings["graph.x_axis.labels_enabled"] === false + getHidden: (series, vizSettings) => + vizSettings["graph.x_axis.labels_enabled"] === false, + getDefault: (series, vizSettings) => + series.length === 1 ? getFriendlyName(series[0].data.cols[0]) : null }, "graph.y_axis.labels_enabled": { section: "Labels", @@ -402,7 +411,10 @@ const SETTINGS = { section: "Labels", title: "Y-axis label", widget: ChartSettingInput, - getHidden: (series, vizSettings) => vizSettings["graph.y_axis.labels_enabled"] === false + getHidden: (series, vizSettings) => + vizSettings["graph.y_axis.labels_enabled"] === false, + getDefault: (series, vizSettings) => + series.length === 1 ? getFriendlyName(series[0].data.cols[1]) : null }, "pie.dimension": { section: "Data", @@ -652,6 +664,10 @@ function getSetting(id, vizSettings, series) { getSetting(dependentId, vizSettings, series); } + if (settingDef.useRawSeries && series._raw) { + series = series._raw; + } + try { if (settingDef.getValue) { return vizSettings[id] = settingDef.getValue(series, vizSettings); @@ -678,7 +694,7 @@ function getSetting(id, vizSettings, series) { function getSettingIdsForSeries(series) { const [{ card }] = series; - const prefixes = SETTINGS_PREFIXES_BY_CHART_TYPE[card.display] || []; + const prefixes = (SETTINGS_PREFIXES_BY_CHART_TYPE[card.display] || []).concat("card."); return Object.keys(SETTINGS).filter(id => _.any(prefixes, (p) => id.startsWith(p))) } @@ -700,6 +716,9 @@ function getSettingWidget(id, vizSettings, series, onChangeSettings) { } onChangeSettings(newSettings) } + if (settingDef.useRawSeries && series._raw) { + series = series._raw; + } return { ...settingDef, id: id, @@ -715,9 +734,12 @@ function getSettingWidget(id, vizSettings, series, onChangeSettings) { }; } -export function getSettingsWidgets(series, onChangeSettings) { +export function getSettingsWidgets(series, onChangeSettings, isDashboard = false) { const vizSettings = getSettings(series); return getSettingIdsForSeries(series).map(id => getSettingWidget(id, vizSettings, series, onChangeSettings) - ).filter(widget => widget.widget && !widget.hidden); + ).filter(widget => + widget.widget && !widget.hidden && + (widget.dashboard === undefined || widget.dashboard === isDashboard) + ); } diff --git a/frontend/src/metabase/query_builder/VisualizationSettings.jsx b/frontend/src/metabase/query_builder/VisualizationSettings.jsx index d7db17ee26de4beb083a7d197c0e132fee382853..1bdca698efff8e8a1019cca2fba9550d4711e6c7 100644 --- a/frontend/src/metabase/query_builder/VisualizationSettings.jsx +++ b/frontend/src/metabase/query_builder/VisualizationSettings.jsx @@ -6,7 +6,7 @@ import ModalWithTrigger from "metabase/components/ModalWithTrigger.jsx"; import ChartSettings from "metabase/visualizations/components/ChartSettings.jsx"; -import visualizations from "metabase/visualizations"; +import visualizations, { getVisualizationRaw } from "metabase/visualizations"; import cx from "classnames"; @@ -31,12 +31,12 @@ export default class VisualizationSettings extends React.Component { renderChartTypePicker() { let { result, card } = this.props; - let visualization = visualizations.get(card.display); + let { CardVisualization } = getVisualizationRaw([{ card, data: result.data }]); var triggerElement = ( <span className="px2 py1 text-bold cursor-pointer text-default flex align-center"> - <Icon name={visualization.iconName} size={12} /> - {visualization.displayName} + <Icon name={CardVisualization.iconName} size={12} /> + {CardVisualization.displayName} <Icon className="ml1" name="chevrondown" size={8} /> </span> ); @@ -56,7 +56,8 @@ export default class VisualizationSettings extends React.Component { key={index} className={cx('p2 flex align-center cursor-pointer bg-brand-hover text-white-hover', { 'ChartType--selected': vizType === card.display, - 'ChartType--notSensible': !(result && result.data && viz.isSensible(result.data.cols, result.data.rows)) + 'ChartType--notSensible': !(result && result.data && viz.isSensible(result.data.cols, result.data.rows)), + 'hide': viz.hidden })} onClick={this.setDisplay.bind(null, vizType)} > @@ -70,21 +71,6 @@ export default class VisualizationSettings extends React.Component { ); } - renderVisualizationSettings() { - let { card } = this.props; - let visualization = visualizations.get(card.display); - return ( - visualization.settings && visualization.settings.map((VisualizationSetting, index) => - <VisualizationSetting - key={index} - settings={card.visualization_settings} - onUpdateVisualizationSetting={this.props.onUpdateVisualizationSetting} - onUpdateVisualizationSettings={this.props.onUpdateVisualizationSettings} - /> - ) - ); - } - render() { if (this.props.result && this.props.result.error === undefined) { return ( @@ -100,7 +86,6 @@ export default class VisualizationSettings extends React.Component { onChange={this.props.onUpdateVisualizationSettings} /> </ModalWithTrigger> - {this.renderVisualizationSettings()} </div> ); } else { diff --git a/frontend/src/metabase/questions/selectors.js b/frontend/src/metabase/questions/selectors.js index a05410bb00b8214f53408daad9ff877f331ee3b5..b5c35503af769fb8f4fa1453b140eceeb3afc3b8 100644 --- a/frontend/src/metabase/questions/selectors.js +++ b/frontend/src/metabase/questions/selectors.js @@ -58,7 +58,7 @@ export const makeGetItem = () => { id: entity.id, created: moment(entity.created_at).fromNow(), by: entity.creator.common_name, - icon: (visualizations.get(entity.display)||{}).iconName, + icon: visualizations.get(entity.display).iconName, favorite: entity.favorite, archived: entity.archived, labels: entity.labels.map(labelId => labelEntities[labelId]).filter(l => l), diff --git a/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx b/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx index 0d45f4e72556728c242dfb15a700110f048e7176..33d4ff47eddc3b9e11c418b6f86258f5ddaecc23 100644 --- a/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx @@ -60,7 +60,7 @@ const createListItem = (entity, index, section) => `/card/${entity.id}` } icon={section.type === 'questions' ? - (visualizations.get(entity.display)||{}).iconName : + visualizations.get(entity.display).iconName : section.icon } /> diff --git a/frontend/src/metabase/visualizations/Funnel.jsx b/frontend/src/metabase/visualizations/Funnel.jsx new file mode 100644 index 0000000000000000000000000000000000000000..cd721610c1ec01b8226c2eb1fbfbb75d15805bf9 --- /dev/null +++ b/frontend/src/metabase/visualizations/Funnel.jsx @@ -0,0 +1,63 @@ +import React, { Component, PropTypes } from "react"; +import ReactDOM from "react-dom"; + +import BarChart from "./BarChart.jsx"; + +import { formatValue } from "metabase/lib/formatting"; +import { getSettings } from "metabase/lib/visualization_settings"; +import { isNumeric } from "metabase/lib/schema_metadata"; +import i from "icepick"; + +export default class Funnel extends Component { + static displayName = "Funnel"; + static identifier = "funnel"; + static iconName = "funnel"; + + static minSize = { width: 3, height: 3 }; + static noHeader = true; + + static hidden = true; + + static isSensible(cols, rows) { + return true; + } + + static checkRenderable(cols, rows) { + if (!isNumeric(cols[0])) { + throw new Error("Funnel visualization requires a number."); + } + } + + static transformSeries(series) { + let [{ card, data: { rows, cols }}] = series; + if (!card._transformed && series.length === 1 && rows.length > 1) { + return rows.map(row => ({ + card: { + ...card, + name: formatValue(row[0], { column: cols[0] }), + _transformed: true + }, + data: { + rows: [row], + cols: cols + } + })); + } else { + return series; + } + } + + render() { + return ( + <BarChart + {...this.props} + isScalarSeries={true} + settings={{ + ...this.props.settings, + ...getSettings(i.assocIn(this.props.series, [0, "card", "display"], "bar")), + "bar.scalar_series": true + }} + /> + ); + } +} diff --git a/frontend/src/metabase/visualizations/Progress.jsx b/frontend/src/metabase/visualizations/Progress.jsx index 9c6a7e766a6193dbd4b5634ab9c571b2bddf6082..0377fbc4719f0f49e6a08d51bc833253d106deac 100644 --- a/frontend/src/metabase/visualizations/Progress.jsx +++ b/frontend/src/metabase/visualizations/Progress.jsx @@ -2,6 +2,7 @@ import React, { Component, PropTypes } from "react"; import ReactDOM from "react-dom"; import { formatValue } from "metabase/lib/formatting"; +import { isNumeric } from "metabase/lib/schema_metadata"; import Icon from "metabase/components/Icon.jsx"; import IconBorder from "metabase/components/IconBorder.jsx"; @@ -12,7 +13,7 @@ const BORDER_RADIUS = 5; export default class Progress extends Component { static displayName = "Progress"; static identifier = "progress"; - static iconName = "number"; + static iconName = "progress"; static minSize = { width: 3, height: 3 }; @@ -21,6 +22,9 @@ export default class Progress extends Component { } static checkRenderable(cols, rows) { + if (!isNumeric(cols[0])) { + throw new Error("Progress visualization requires a number."); + } } componentDidMount() { diff --git a/frontend/src/metabase/visualizations/Scalar.jsx b/frontend/src/metabase/visualizations/Scalar.jsx index e17300876d97b1c6af0c930ab7f6fb98b0998b85..259b73535eaed9e938d641a8f65667e0a535a017 100644 --- a/frontend/src/metabase/visualizations/Scalar.jsx +++ b/frontend/src/metabase/visualizations/Scalar.jsx @@ -3,13 +3,10 @@ import { Link } from "react-router"; import styles from "./Scalar.css"; import Ellipsified from "metabase/components/Ellipsified.jsx"; -import BarChart from "./BarChart.jsx"; import Urls from "metabase/lib/urls"; import { formatValue } from "metabase/lib/formatting"; import { TYPE } from "metabase/lib/types"; -import { isSameSeries } from "metabase/visualizations/lib/utils"; -import { getSettings } from "metabase/lib/visualization_settings"; import cx from "classnames"; import i from "icepick"; @@ -40,31 +37,10 @@ export default class Scalar extends Component { return false; } - constructor(props, context) { - super(props, context); - this.state = { - series: null, - isMultiseries: null - }; - } - - componentWillMount() { - this.transformSeries(this.props); - } - - componentWillReceiveProps(newProps) { - if (isSameSeries(newProps.series, this.props.series)) { - return; - } - this.transformSeries(newProps); - } - - transformSeries(newProps) { - let series = newProps.series; - let isMultiseries = false; - if (newProps.isMultiseries || newProps.series.length > 1) { - series = newProps.series.map(s => ({ - card: { ...s.card, display: "bar" }, + static transformSeries(series) { + if (series.length > 1) { + return series.map(s => ({ + card: { ...s.card, display: "funnel" }, data: { cols: [ { base_type: TYPE.Text, display_name: "Name", name: "dimension" }, @@ -74,31 +50,14 @@ export default class Scalar extends Component { ] } })); - isMultiseries = true; + } else { + return series; } - this.setState({ - series, - isMultiseries - }); } render() { let { card, data, className, actionButtons, gridSize, settings } = this.props; - if (this.state.isMultiseries) { - return ( - <BarChart - {...this.props} - series={this.state.series} - isScalarSeries={true} - settings={{ - ...settings, - ...getSettings(this.state.series) - }} - /> - ); - } - let isSmall = gridSize && gridSize.width < 4; const column = i.getIn(data, ["cols", 0]); @@ -168,7 +127,7 @@ export default class Scalar extends Component { {compactScalarValue} </Ellipsified> <Ellipsified className={styles.Title} tooltip={card.name}> - <Link to={Urls.card(card.id)} className="no-decoration fullscreen-normal-text fullscreen-night-text">{card.name}</Link> + <Link to={Urls.card(card.id)} className="no-decoration fullscreen-normal-text fullscreen-night-text">{settings["card.title"]}</Link> </Ellipsified> </div> ); diff --git a/frontend/src/metabase/visualizations/ScatterPlot.jsx b/frontend/src/metabase/visualizations/ScatterPlot.jsx index 76de5e1c6f2bb980b84979f500160a9f8274a222..0f5af3c5857028dcf750249f9dba71e4d689cbdd 100644 --- a/frontend/src/metabase/visualizations/ScatterPlot.jsx +++ b/frontend/src/metabase/visualizations/ScatterPlot.jsx @@ -5,6 +5,6 @@ import LineAreaBarChart from "./components/LineAreaBarChart.jsx"; export default class ScatterPlot extends LineAreaBarChart { static displayName = "Scatter"; static identifier = "scatter"; - static iconName = "line"; + static iconName = "bubble"; static noun = "scatter plot"; } diff --git a/frontend/src/metabase/visualizations/components/ChartSettings.jsx b/frontend/src/metabase/visualizations/components/ChartSettings.jsx index 10374c314c2bcb995a6a51cf1928b994bea71879..215b57bc103200e414b67d58c78c6a3df99f307b 100644 --- a/frontend/src/metabase/visualizations/components/ChartSettings.jsx +++ b/frontend/src/metabase/visualizations/components/ChartSettings.jsx @@ -6,6 +6,7 @@ import _ from "underscore"; import Visualization from "metabase/visualizations/components/Visualization.jsx" import { getSettingsWidgets } from "metabase/lib/visualization_settings"; import MetabaseAnalytics from "metabase/lib/analytics"; +import { getVisualizationTransformed } from "metabase/visualizations"; const ChartSettingsTab = ({name, active, onClick}) => <a @@ -71,32 +72,37 @@ class ChartSettings extends Component { this.props.onClose(); } - getSeries() { - return assocIn(this.props.series, [0, "card", "visualization_settings"], this.state.settings); - } - - getChartTypeName(){ - switch(this.props.series[0].card.display){ - case "table": - return "table"; - case "scalar": - return "number"; - default: - return "chart"; - } + getChartTypeName() { + let { CardVisualization } = getVisualizationTransformed(this.props.series); + switch (CardVisualization.identifier) { + case "table": return "table"; + case "scalar": return "number"; + case "funnel": return "funnel"; + default: return "chart"; + } } - render () { - const { onClose } = this.props; + let { series, onClose, isDashboard } = this.props; - const series = this.getSeries(); + series = assocIn(series, [0, "card", "visualization_settings"], this.state.settings); + + const transformed = getVisualizationTransformed(series); + series = transformed.series; const tabs = {}; - for (let widget of getSettingsWidgets(series, this.onChangeSettings)) { + for (let widget of getSettingsWidgets(series, this.onChangeSettings, isDashboard)) { tabs[widget.section] = tabs[widget.section] || []; tabs[widget.section].push(widget); } + + // Move settings from the "undefined" section in the first tab + if (tabs["undefined"] && Object.values(tabs).length > 1) { + let extra = tabs["undefined"]; + delete tabs["undefined"]; + Object.values(tabs)[0].unshift(...extra); + } + const tabNames = Object.keys(tabs); const currentTab = this.state.currentTab || tabNames[0]; const widgets = tabs[currentTab]; @@ -118,7 +124,8 @@ class ChartSettings extends Component { <Visualization className="spread" series={series} - isEditing={true} + isEditing + isDashboard onUpdateVisualizationSetting={this.onUpdateVisualizationSetting} /> </div> diff --git a/frontend/src/metabase/visualizations/components/LegendHeader.jsx b/frontend/src/metabase/visualizations/components/LegendHeader.jsx index caf025424f1931d8dda2218904cc6c4fa684a68a..63fe699157a492533693156a2a217dd98698c5fc 100644 --- a/frontend/src/metabase/visualizations/components/LegendHeader.jsx +++ b/frontend/src/metabase/visualizations/components/LegendHeader.jsx @@ -30,7 +30,8 @@ export default class LegendHeader extends Component { }; static defaultProps = { - series: [] + series: [], + settings: {} }; componentDidMount() { diff --git a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx index 5e9e6fe99c5c555d1c22f7bbceee837bba2ec799..c527b34e67810cc5e611d0ff26b4450ba58195d8 100644 --- a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx +++ b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx @@ -8,11 +8,12 @@ import lineAreaBarRenderer from "metabase/visualizations/lib/LineAreaBarRenderer import { isNumeric, isDate } from "metabase/lib/schema_metadata"; import { - isSameSeries, getChartTypeFromData, getFriendlyName } from "metabase/visualizations/lib/utils"; +import { getSettings } from "metabase/lib/visualization_settings"; + import { MinRowsError, ChartSettingsError } from "metabase/visualizations/lib/errors"; import crossfilter from "crossfilter"; @@ -39,25 +40,43 @@ export default class LineAreaBarChart extends Component { } static seriesAreCompatible(initialSeries, newSeries) { - if (newSeries.card.dataset_query.type === "query") { - // no bare rows - if (newSeries.card.dataset_query.query.aggregation[0] === "rows") { - return false; - } - // must have one and only one breakout - if (newSeries.card.dataset_query.query.breakout.length !== 1) { - return false; - } + let initialSettings = getSettings([initialSeries]); + let newSettings = getSettings([newSeries]); + + let initialDimensions = getColumnsFromNames(initialSeries.data.cols, initialSettings["graph.dimensions"]); + let newDimensions = getColumnsFromNames(newSeries.data.cols, newSettings["graph.dimensions"]); + let newMetrics = getColumnsFromNames(newSeries.data.cols, newSettings["graph.metrics"]); + + // must have at least one dimension and one metric + if (newDimensions.length === 0 || newMetrics.length === 0) { + return false; + } + + // all metrics must be numeric + if (!_.all(newMetrics, isNumeric)) { + return false; + } + + // both or neither primary dimension must be dates + if (isDate(initialDimensions[0]) !== isDate(newDimensions[0])) { + return false; + } + + // both or neither primary dimension must be numeric + if (isNumeric(initialDimensions[0]) !== isNumeric(newDimensions[0])) { + return false; } - return columnsAreCompatible(initialSeries.data.cols, newSeries.data.cols); + return true; } - constructor(props, context) { - super(props, context); - this.state = { - series: null, - }; + static transformSeries(series) { + let newSeries = [].concat(...series.map(transformSingleSeries)); + if (_.isEqual(series, newSeries) || newSeries.length === 0) { + return series; + } else { + return newSeries; + } } static propTypes = { @@ -70,82 +89,6 @@ export default class LineAreaBarChart extends Component { static defaultProps = { }; - componentWillMount() { - this.transformSeries(this.props); - } - - componentWillReceiveProps(newProps) { - if (isSameSeries(newProps.series, this.props.series)) { - return; - } - this.transformSeries(newProps); - } - - transformSeries(newProps) { - let { series, settings } = newProps; - let nextState = { - series: series, - }; - let s = series && series.length === 1 && series[0]; - if (s && s.data) { - const { cols, rows } = s.data; - - const dimensions = settings["graph.dimensions"].filter(d => d != null); - const metrics = settings["graph.metrics"].filter(d => d != null); - const dimensionIndexes = dimensions.map(dimensionName => - _.findIndex(cols, (col) => col.name === dimensionName) - ); - const metricIndexes = metrics.map(metricName => - _.findIndex(cols, (col) => col.name === metricName) - ); - - const bubbleIndex = settings["scatter.bubble"] && _.findIndex(cols, (col) => col.name === settings["scatter.bubble"]); - const extraIndexes = bubbleIndex && bubbleIndex >= 0 ? [bubbleIndex] : []; - - if (dimensions.length > 1) { - const dataset = crossfilter(rows); - const [dimensionIndex, seriesIndex] = dimensionIndexes; - const rowIndexes = [dimensionIndex].concat(metricIndexes, extraIndexes); - const seriesGroup = dataset.dimension(d => d[seriesIndex]).group() - nextState.series = seriesGroup.reduce( - (p, v) => p.concat([rowIndexes.map(i => v[i])]), - (p, v) => null, () => [] - ).all().map(o => ({ - card: { - ...s.card, - id: null, - name: o.key - }, - data: { - rows: o.value, - cols: rowIndexes.map(i => s.data.cols[i]) - } - })); - } else { - const dimensionIndex = dimensionIndexes[0]; - - nextState.series = metricIndexes.map(metricIndex => { - const col = cols[metricIndex]; - const rowIndexes = [dimensionIndex].concat(metricIndex, extraIndexes); - return { - card: { - ...s.card, - id: null, - name: getFriendlyName(col) - }, - data: { - rows: rows.map(row => - rowIndexes.map(i => row[i]) - ), - cols: rowIndexes.map(i => s.data.cols[i]) - } - }; - }); - } - } - this.setState(nextState); - } - getHoverClasses() { const { hovered } = this.props; if (hovered && hovered.index != null) { @@ -211,38 +154,45 @@ export default class LineAreaBarChart extends Component { } render() { - const { hovered, isDashboard, actionButtons } = this.props; - const { series } = this.state; + const { series, hovered, isDashboard, actionButtons } = this.props; const settings = this.getSettings(); - const isMultiseries = this.state.series.length > 1; - const isDashboardMultiseries = this.props.series.length > 1; - const isCardMultiseries = isMultiseries && !isDashboardMultiseries; + let titleHeaderSeries, multiseriesHeaderSeries; + + let originalSeries = series._raw || series; + let cardIds = _.uniq(originalSeries.map(s => s.card.id)) + + if (isDashboard && settings["card.title"]) { + titleHeaderSeries = [{ card: { + name: settings["card.title"], + id: cardIds.length === 1 ? cardIds[0] : null + }}]; + } + + if (series.length > 1) { + multiseriesHeaderSeries = series; + } return ( <div className={cx("flex flex-column p1", this.getHoverClasses(), this.props.className)}> - {/* This is always used to show the original card titles/links + action buttons */} - { isDashboard && + { titleHeaderSeries ? <LegendHeader className="flex-no-shrink" - series={this.props.series} + series={titleHeaderSeries} actionButtons={actionButtons} - hovered={hovered} - onHoverChange={this.props.onHoverChange} - settings={settings} /> - } - {/* This only shows transformed card multiseries titles */} - { isCardMultiseries && + : null } + { multiseriesHeaderSeries || (!titleHeaderSeries && actionButtons) ? // always show action buttons if we have them <LegendHeader className="flex-no-shrink" - series={series} + series={multiseriesHeaderSeries} + settings={settings} hovered={hovered} onHoverChange={this.props.onHoverChange} - settings={settings} + actionButtons={!titleHeaderSeries ? actionButtons : null} /> - } + : null } <CardRenderer {...this.props} chartType={this.getChartType()} @@ -257,21 +207,73 @@ export default class LineAreaBarChart extends Component { } } -function columnsAreCompatible(colsA, colsB) { - if (!(colsA && colsB && colsA.length >= 2 && colsB.length >= 2)) { - return false; +function getColumnsFromNames(cols, names) { + if (!names) { + return []; } - // second column must be numeric - if (!isNumeric(colsA[1]) || !isNumeric(colsB[1])) { - return false; - } - // both or neither must be dates - if (isDate(colsA[0]) !== isDate(colsB[0])) { - return false; + return names.map(name => _.findWhere(cols, { name })); +} + +function transformSingleSeries(s) { + const { card, data } = s; + + // HACK: prevents cards from being transformed too many times + if (card._transformed) { + return [s]; } - // both or neither must be numeric - if (isNumeric(colsA[0]) !== isNumeric(colsB[0])) { - return false; + + const { cols, rows } = data; + const settings = getSettings([s]); + + const dimensions = settings["graph.dimensions"].filter(d => d != null); + const metrics = settings["graph.metrics"].filter(d => d != null); + const dimensionIndexes = dimensions.map(dimensionName => + _.findIndex(cols, (col) => col.name === dimensionName) + ); + const metricIndexes = metrics.map(metricName => + _.findIndex(cols, (col) => col.name === metricName) + ); + + const bubbleIndex = settings["scatter.bubble"] && _.findIndex(cols, (col) => col.name === settings["scatter.bubble"]); + const extraIndexes = bubbleIndex && bubbleIndex >= 0 ? [bubbleIndex] : []; + + if (dimensions.length > 1) { + const dataset = crossfilter(rows); + const [dimensionIndex, seriesIndex] = dimensionIndexes; + const rowIndexes = [dimensionIndex].concat(metricIndexes, extraIndexes); + const seriesGroup = dataset.dimension(d => d[seriesIndex]).group() + return seriesGroup.reduce( + (p, v) => p.concat([rowIndexes.map(i => v[i])]), + (p, v) => null, () => [] + ).all().map(o => ({ + card: { + ...card, + name: o.key, + _transformed: true, + }, + data: { + rows: o.value, + cols: rowIndexes.map(i => cols[i]) + } + })); + } else { + const dimensionIndex = dimensionIndexes[0]; + return metricIndexes.map(metricIndex => { + const col = cols[metricIndex]; + const rowIndexes = [dimensionIndex].concat(metricIndex, extraIndexes); + return { + card: { + ...card, + name: getFriendlyName(col), + _transformed: true, + }, + data: { + rows: rows.map(row => + rowIndexes.map(i => row[i]) + ), + cols: rowIndexes.map(i => cols[i]) + } + }; + }); } - return true; } diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index 8acb19c64cbda031f938eb524a38d0127eba6a0b..2edb36dfddb5259771442ab74b002b9e8b0c5e64 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -8,8 +8,9 @@ import Tooltip from "metabase/components/Tooltip.jsx"; import { duration } from "metabase/lib/formatting"; -import visualizations from "metabase/visualizations"; +import { getVisualizationTransformed } from "metabase/visualizations"; import { getSettings } from "metabase/lib/visualization_settings"; +import { isSameSeries } from "metabase/visualizations/lib/utils"; import { assoc, getIn } from "icepick"; import _ from "underscore"; @@ -47,9 +48,24 @@ export default class Visualization extends Component { onUpdateVisualizationSetting: (...args) => console.warn("onUpdateVisualizationSetting", args) }; - componentWillReceiveProps() { - // clear the error so we can try to render again - this.setState({ error: null }); + componentWillMount() { + this.transform(this.props); + } + + componentWillReceiveProps(newProps) { + if (isSameSeries(newProps.series, this.props.series)) { + // clear the error so we can try to render again + this.setState({ error: null }); + } else { + this.transform(newProps); + } + } + + transform(newProps) { + this.setState({ + error: null, + ...getVisualizationTransformed(newProps.series) + }); } onHoverChange(hovered) { @@ -73,8 +89,8 @@ export default class Visualization extends Component { } render() { - const { series, actionButtons, className, isDashboard, width, errorIcon, isSlow, expectedDuration, replacementContent } = this.props; - const CardVisualization = visualizations.get(series[0].card.display); + const { actionButtons, className, isDashboard, width, errorIcon, isSlow, expectedDuration, replacementContent } = this.props; + const { series, CardVisualization } = this.state; const small = width < 330; let error = this.props.error || this.state.error; @@ -117,10 +133,10 @@ export default class Visualization extends Component { return ( <div className={cx(className, "flex flex-column")}> - { isDashboard && (loading || error || !CardVisualization.noHeader) || replacementContent ? + { isDashboard && (settings["card.title"] || extra) && (loading || error || !CardVisualization.noHeader) || replacementContent ? <div className="p1 flex-no-shrink"> <LegendHeader - series={series} + series={[{ card: { name: settings["card.title"] }}]} actionButtons={extra} settings={settings} /> diff --git a/frontend/src/metabase/visualizations/index.js b/frontend/src/metabase/visualizations/index.js index e0fa06a869645327747f0e89f9c60c52702a382f..1e3b9937064dcb62e33125511578ae3855427121 100644 --- a/frontend/src/metabase/visualizations/index.js +++ b/frontend/src/metabase/visualizations/index.js @@ -8,6 +8,9 @@ import PieChart from "./PieChart.jsx"; import AreaChart from "./AreaChart.jsx"; import MapViz from "./Map.jsx"; import ScatterPlot from "./ScatterPlot.jsx"; +import Funnel from "./Funnel.jsx"; + +import _ from "underscore"; const visualizations = new Map(); const aliases = new Map(); @@ -29,6 +32,36 @@ export function registerVisualization(visualization) { } } +export function getVisualizationRaw(series) { + return { + series: series, + CardVisualization: visualizations.get(series[0].card.display) + }; +} + +export function getVisualizationTransformed(series) { + // don't transform if we don't have the data + if (_.any(series, s => s.data == null)) { + return getVisualizationRaw(series); + } + + // if a visualization has a transformSeries function, do the transformation until it returns the same visualization / series + let CardVisualization, lastSeries; + do { + CardVisualization = visualizations.get(series[0].card.display); + lastSeries = series; + if (typeof CardVisualization.transformSeries === "function") { + series = CardVisualization.transformSeries(series); + } + if (series !== lastSeries) { + series = [...series]; + series._raw = lastSeries; + } + } while (series !== lastSeries); + + return { series, CardVisualization }; +} + registerVisualization(Scalar); registerVisualization(Progress); registerVisualization(Table); @@ -38,6 +71,7 @@ registerVisualization(AreaChart); registerVisualization(ScatterPlot); registerVisualization(PieChart); registerVisualization(MapViz); +registerVisualization(Funnel); import { enableVisualizationEasterEgg } from "./lib/utils"; import XKCDChart from "./XKCDChart.jsx"; diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js index 49c40f3565a9980716bcbd024d957e86d1f42d87..ab536ee6dabe9fa94bc8901f2bd26519ab97a951 100644 --- a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js +++ b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js @@ -13,12 +13,14 @@ import { } from "./utils"; import { + dimensionIsTimeseries, minTimeseriesUnit, computeTimeseriesDataInverval, computeTimeseriesTicksInterval } from "./timeseries"; import { + dimensionIsNumeric, computeNumericDataInverval } from "./numeric"; @@ -71,6 +73,9 @@ function applyChartTimeseriesXAxis(chart, settings, series, xValues, xDomain, xI // setup an x-axis where the dimension is a timeseries let dimensionColumn = series[0].data.cols[0]; + let dataOffset = parseTimestamp(series[0].data.rows[0][0]).utcOffset() / 60; + let browserOffset = moment().utcOffset() / 60; + // compute the data interval let dataInterval = xInterval; let tickInterval = dataInterval; @@ -86,9 +91,13 @@ function applyChartTimeseriesXAxis(chart, settings, series, xValues, xDomain, xI } chart.xAxis().tickFormat(timestamp => { - // HACK: these dates are in the browser's timezone, change to UTC - let timestampUTC = moment(timestamp).format().replace(/[+-]\d+:\d+$/, "Z"); - return formatValue(timestampUTC, { column: dimensionColumn }) + let timestampFixed; + if (dataOffset === 0) { + timestampFixed = moment(timestamp).add(-browserOffset, "hour").utcOffset(dataOffset).format(); + } else { + timestampFixed = moment(timestamp).utcOffset(dataOffset).format(); + } + return formatValue(timestampFixed, { column: dimensionColumn }) }); // Compute a sane interval to display based on the data granularity, domain, and chart width @@ -99,8 +108,8 @@ function applyChartTimeseriesXAxis(chart, settings, series, xValues, xDomain, xI } // pad the domain slightly to prevent clipping - xDomain[0] = moment(xDomain[0]).subtract(dataInterval.count * 0.75, dataInterval.interval).toDate(); - xDomain[1] = moment(xDomain[1]).add(dataInterval.count * 0.75, dataInterval.interval).toDate(); + xDomain[0] = moment(xDomain[0]).subtract(dataInterval.count * 0.75, dataInterval.interval); + xDomain[1] = moment(xDomain[1]).add(dataInterval.count * 0.75, dataInterval.interval); // set the x scale chart.x(d3.time.scale.utc().domain(xDomain));//.nice(d3.time[dataInterval.interval])); @@ -500,7 +509,15 @@ function lineAndBarOnRender(chart, settings, onGoalHover, isSplitAxis) { // add the label let goalLine = chart.selectAll(".goal .line")[0][0]; if (goalLine) { + + // stretch the goal line all the way across, use x axis as reference + let xAxisLine = chart.selectAll(".axis.x .domain")[0][0]; + if (xAxisLine) { + goalLine.setAttribute("d", `M0,${goalLine.getBBox().y}L${xAxisLine.getBBox().width},${goalLine.getBBox().y}`) + } + let { x, y, width } = goalLine.getBBox(); + const labelOnRight = !isSplitAxis; chart.selectAll(".goal .stack._0") .append("text") @@ -541,12 +558,12 @@ function lineAndBarOnRender(chart, settings, onGoalHover, isSplitAxis) { setDotStyle(); enableDots(); voronoiHover(); + cleanupGoal(); // do this before hiding x-axis hideDisabledLabels(); hideDisabledAxis(); hideBadAxis(); disableClickFiltering(); fixStackZIndex(); - cleanupGoal(); }); chart.render(); @@ -590,12 +607,28 @@ function fillMissingValues(datas, xValues, fillValue, getKey = (v) => v) { } } +// Crossfilter calls toString on each moment object, which calls format(), which is very slow. +// Replace toString with a function that just returns the unparsed ISO input date, since that works +// just as well and is much faster +let HACK_parseTimestamp = (value, unit) => { + let m = parseTimestamp(value, unit); + m.toString = moment_fast_toString + return m; +} + +function moment_fast_toString() { + return this._i; +} + export default function lineAreaBar(element, { series, onHoverChange, onRender, chartType, isScalarSeries, settings }) { const colors = settings["graph.colors"]; const isTimeseries = settings["graph.x_axis.scale"] === "timeseries"; const isQuantitative = ["linear", "log", "pow"].indexOf(settings["graph.x_axis.scale"]) >= 0; + const isDimensionTimeseries = dimensionIsTimeseries(series[0].data); + const isDimensionNumeric = dimensionIsNumeric(series[0].data); + if (series[0].data.cols.length < 2) { throw "This chart type requires at least 2 columns"; } @@ -607,9 +640,9 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender, let datas = series.map((s, index) => s.data.rows.map(row => [ // don't parse as timestamp if we're going to display as a quantitative scale, e.x. years and Unix timestamps - (settings["graph.x_axis._is_timeseries"] && !isQuantitative) ? - parseTimestamp(row[0], s.data.cols[0].unit).toDate() - : settings["graph.x_axis._is_numeric"] ? + (isDimensionTimeseries && !isQuantitative) ? + HACK_parseTimestamp(row[0], s.data.cols[0].unit) + : isDimensionNumeric ? row[0] : String(row[0]) @@ -636,12 +669,12 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender, if (isTimeseries) { // replace xValues with xValues = d3.time[xInterval.interval] - .range(xDomain[0], moment(xDomain[1]).add(1, "ms").toDate(), xInterval.count); + .range(xDomain[0], moment(xDomain[1]).add(1, "ms"), xInterval.count); datas = fillMissingValues( datas, xValues, settings["line.missing"] === "zero" ? 0 : null, - (m) => d3.round(m.getTime(), -1) // sometimes rounds up 1ms? + (m) => d3.round(m.toDate().getTime(), -1) // sometimes rounds up 1ms? ); } if (isQuantitative) { xValues = d3.range(xDomain[0], xDomain[1] + xInterval, xInterval); diff --git a/frontend/test/karma.conf.js b/frontend/test/karma.conf.js index 6cdf67a0e5d644f20d1c5ef512f1ef22dd7e1ec8..b9db126702f72af719fed1fbcbfa8c0f2251a5fc 100644 --- a/frontend/test/karma.conf.js +++ b/frontend/test/karma.conf.js @@ -8,7 +8,6 @@ module.exports = function(config) { basePath: '../', files: [ 'test/metabase-bootstrap.js', - '../node_modules/angular-mocks/angular-mocks.js', 'test/unit/**/*.spec.js' ], exclude: [ diff --git a/frontend/test/unit/visualizations/lib/LineAreaBarRenderer.spec.js b/frontend/test/unit/visualizations/lib/LineAreaBarRenderer.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..cd05e61f9bd43ca9db744958a06454b612f59dd7 --- /dev/null +++ b/frontend/test/unit/visualizations/lib/LineAreaBarRenderer.spec.js @@ -0,0 +1,135 @@ + +import lineAreaBarRenderer from "metabase/visualizations/lib/LineAreaBarRenderer"; +import { formatValue } from "metabase/lib/formatting"; + +import d3 from "d3"; + +const Column = (col = {}) => ({ name: "x", display_name: "x", ...col }) +const DateTimeColumn = (col = {}) => Column({ "base_type" : "type/DateTime", "special_type" : null, ...col }) +const NumberColumn = (col = {}) => Column({ "base_type" : "type/Integer", "special_type" : "type/Number", ...col }) + + +let formatTz = (offset) => (offset < 0 ? "-" : "+") + d3.format("02d")(Math.abs(offset)) + ":00" + +const BROWSER_TZ = formatTz(- new Date().getTimezoneOffset() / 60); +const ALL_TZS = d3.range(-1, 2).map(formatTz); + + +describe("LineAreaBarRenderer", () => { + let element; + + beforeEach(function() { + document.body.insertAdjacentHTML('afterbegin', '<div id="fixture" style="height: 800px; width: 1200px;">'); + element = document.getElementById('fixture'); + }); + + afterEach(function() { + document.body.removeChild(document.getElementById('fixture')); + }); + + it("should display numeric year in X-axis and tooltip correctly", (done) => { + renderTimeseriesLine({ + rows: [ + [2015, 1], + [2016, 2], + [2017, 3] + ], + unit: "year", + onHoverChange: (hover) => { + expect(formatValue(hover.data[0].value, { column: hover.data[0].col })).toEqual( + "2015" + ); + expect(qsa("svg .axis.x .tick text").map(e => e.textContent)).toEqual([ + "2015", + "2016", + "2017" + ]); + done(); + } + }); + dispatchUIEvent(qs("svg .dot"), "mousemove"); + }); + + ["Z", ...ALL_TZS].forEach(tz => + it("should display hourly data (in " + tz + " timezone) in X axis and tooltip consistently", (done) => { + let rows = [ + ["2016-10-03T20:00:00.000" + tz, 1], + ["2016-10-03T21:00:00.000" + tz, 1], + ]; + renderTimeseriesLine({ + rows, + unit: "hour", + onHoverChange: (hover) => { + let expected = rows.map(row => formatValue(row[0], { column: DateTimeColumn({ unit: "hour" }) })); + expect(formatValue(hover.data[0].value, { column: hover.data[0].col })).toEqual( + expected[0] + ); + expect(qsa("svg .axis.x .tick text").map(e => e.textContent)).toEqual(expected); + done(); + } + }) + dispatchUIEvent(qs("svg .dot"), "mousemove"); + }) + ) + + it("should display hourly data (in the browser's timezone) in X axis and tooltip consistently and correctly", function(done) { + let tz = BROWSER_TZ; + let rows = [ + ["2016-01-01T01:00:00.000" + tz, 1], + ["2016-01-01T02:00:00.000" + tz, 1], + ["2016-01-01T03:00:00.000" + tz, 1], + ["2016-01-01T04:00:00.000" + tz, 1] + ]; + renderTimeseriesLine({ + rows, + unit: "hour", + onHoverChange: (hover) => { + expect(formatValue(rows[0][0], { column: DateTimeColumn({ unit: "hour" }) })).toEqual( + '1 AM - January 1, 2016' + ) + expect(formatValue(hover.data[0].value, { column: hover.data[0].col })).toEqual( + '1 AM - January 1, 2016' + ); + expect(qsa("svg .axis.x .tick text").map(e => e.textContent)).toEqual([ + '1 AM - January 1, 2016', + '2 AM - January 1, 2016', + '3 AM - January 1, 2016', + '4 AM - January 1, 2016' + ]); + done(); + } + }); + dispatchUIEvent(qs("svg .dot"), "mousemove"); + }); + + // querySelector shortcut + const qs = (selector) => element.querySelector(selector); + + // querySelectorAll shortcut, casts to Array + const qsa = (selector) => [...element.querySelectorAll(selector)]; + + // helper for timeseries line charts + const renderTimeseriesLine = ({ rows, onHoverChange, unit }) => { + lineAreaBarRenderer(element, { + chartType: "line", + series: [{ + data: { + "cols" : [DateTimeColumn({ unit }), NumberColumn()], + "rows" : rows + } + }], + settings: { + "graph.x_axis.scale": "timeseries", + "graph.x_axis.axis_enabled": true, + "graph.colors": ["#000000"] + }, + onHoverChange + }); + } +}); + +function dispatchUIEvent(element, eventName) { + let e = document.createEvent("UIEvents"); + e.initUIEvent(eventName, true, true, window, 1); + element.dispatchEvent(e); +} diff --git a/resources/migrations/045_add_dashcard_visualization_settings_field.yaml b/resources/migrations/045_add_dashcard_visualization_settings_field.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b1cf1d99c7c90b01b0033e5505c1a1e42b70d900 --- /dev/null +++ b/resources/migrations/045_add_dashcard_visualization_settings_field.yaml @@ -0,0 +1,17 @@ +databaseChangeLog: + - changeSet: + id: 45 + author: tlrobinson + changes: + - addColumn: + tableName: report_dashboardcard + columns: + - column: + name: visualization_settings + type: text + constraints: + nullable: true + deferrable: false + initiallyDeferred: false + nullable: false + defaultValue: '{}' diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj index 9dd7519f485081720050648f444852b573645664..079ac2ae55b58818241e7a7b3d3c57929b53b964 100644 --- a/src/metabase/api/dashboard.clj +++ b/src/metabase/api/dashboard.clj @@ -87,10 +87,11 @@ parameter_mappings [ArrayOfMaps]} (write-check Dashboard id) (read-check Card cardId) - (let [defaults {:dashboard_id id - :card_id cardId - :creator_id *current-user-id* - :series (or series [])} + (let [defaults {:dashboard_id id + :card_id cardId + :visualization_settings {} + :creator_id *current-user-id* + :series (or series [])} dashboard-card (-> (merge dashboard-card defaults) (update :series #(filter identity (map :id %))))] (let-500 [result (create-dashboard-card! dashboard-card)] diff --git a/src/metabase/models/dashboard_card.clj b/src/metabase/models/dashboard_card.clj index 9f5f5c518660cda1a18afe6e5532aca0aa13128a..722f57edcbabdd2edb5545c41cf8c78f3e6884b4 100644 --- a/src/metabase/models/dashboard_card.clj +++ b/src/metabase/models/dashboard_card.clj @@ -24,9 +24,10 @@ (i/perms-objects-set series-card read-or-write))))) (defn- pre-insert [dashcard] - (let [defaults {:sizeX 2 - :sizeY 2 - :parameter_mappings []}] + (let [defaults {:sizeX 2 + :sizeY 2 + :parameter_mappings [] + :visualization_settings {}}] (merge defaults dashcard))) (defn- pre-cascade-delete [{:keys [id]}] @@ -36,7 +37,7 @@ i/IEntity (merge i/IEntityDefaults {:timestamped? (constantly true) - :types (constantly {:parameter_mappings :json}) + :types (constantly {:parameter_mappings :json, :visualization_settings :json}) :perms-objects-set perms-objects-set :can-read? (partial i/current-user-has-full-permissions? :read) :can-write? (partial i/current-user-has-full-permissions? :write) @@ -98,15 +99,16 @@ (defn update-dashboard-card! "Update an existing `DashboardCard`, including all `DashboardCardSeries`. Returns the updated `DashboardCard` or throws an Exception." - [{:keys [id series parameter_mappings] :as dashboard-card}] + [{:keys [id series parameter_mappings visualization_settings] :as dashboard-card}] {:pre [(integer? id) (u/maybe? u/sequence-of-maps? parameter_mappings) + (u/maybe? map? visualization_settings) (every? integer? series)]} (let [{:keys [sizeX sizeY row col series]} (merge {:series []} dashboard-card)] (db/transaction ;; update the dashcard itself (positional attributes) (when (and sizeX sizeY row col) - (db/update-non-nil-keys! DashboardCard id, :sizeX sizeX, :sizeY sizeY, :row row, :col col, :parameter_mappings parameter_mappings)) + (db/update-non-nil-keys! DashboardCard id, :sizeX sizeX, :sizeY sizeY, :row row, :col col, :parameter_mappings parameter_mappings, :visualization_settings visualization_settings)) ;; update series (only if they changed) (when (not= series (map :card_id (db/select [DashboardCardSeries :card_id], :dashboardcard_id id, {:order-by [[:position :asc]]}))) (update-dashboard-card-series! dashboard-card series)) @@ -117,22 +119,24 @@ (defn create-dashboard-card! "Create a new `DashboardCard` by inserting it into the database along with all associated pieces of data such as `DashboardCardSeries`. Returns the newly created `DashboardCard` or throws an Exception." - [{:keys [dashboard_id card_id creator_id parameter_mappings] :as dashboard-card}] + [{:keys [dashboard_id card_id creator_id parameter_mappings visualization_settings] :as dashboard-card}] {:pre [(integer? dashboard_id) (integer? card_id) (integer? creator_id) - (u/maybe? u/sequence-of-maps? parameter_mappings)]} + (u/maybe? u/sequence-of-maps? parameter_mappings) + (u/maybe? map? visualization_settings)]} (let [{:keys [sizeX sizeY row col series]} (merge {:sizeX 2, :sizeY 2, :series []} dashboard-card)] (db/transaction (let [{:keys [id] :as dashboard-card} (db/insert! DashboardCard - :dashboard_id dashboard_id - :card_id card_id - :sizeX sizeX - :sizeY sizeY - :row row - :col col - :parameter_mappings (or parameter_mappings []))] + :dashboard_id dashboard_id + :card_id card_id + :sizeX sizeX + :sizeY sizeY + :row row + :col col + :parameter_mappings (or parameter_mappings []) + :visualization_settings (or visualization_settings {}))] ;; add series to the DashboardCard (update-dashboard-card-series! dashboard-card series) ;; return the full DashboardCard (and record our create event) diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index a12a20f8ea6e5e3a07cfe9ad1d5db8289dcccae5..db549d1aec26a9ca38b5196440d577cb6a12892e 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -108,23 +108,24 @@ :updated_at true :created_at true :parameters [] - :ordered_cards [{:sizeX 2 - :sizeY 2 - :col nil - :row nil - :updated_at true - :created_at true - :parameter_mappings [] - :card {:name "Dashboard Test Card" - :description nil - :creator_id (user->id :rasta) - :creator (user-details (fetch-user :rasta)) - :display "table" - :query_type nil - :dataset_query {} - :visualization_settings {} - :archived false} - :series []}]} + :ordered_cards [{:sizeX 2 + :sizeY 2 + :col nil + :row nil + :updated_at true + :created_at true + :parameter_mappings [] + :visualization_settings {} + :card {:name "Dashboard Test Card" + :description nil + :creator_id (user->id :rasta) + :creator (user-details (fetch-user :rasta)) + :display "table" + :query_type nil + :dataset_query {} + :visualization_settings {} + :archived false} + :series []}]} ;; fetch a dashboard WITH a dashboard card on it (tu/with-temp* [Dashboard [{dashboard-id :id} {:name "Test Dashboard"}] Card [{card-id :id} {:name "Dashboard Test Card"}] @@ -183,45 +184,49 @@ ;; ## POST /api/dashboard/:id/cards ;; simple creation with no additional series (expect - [{:sizeX 2 - :sizeY 2 - :col 4 - :row 4 - :series [] - :parameter_mappings [{:card-id 123, :hash "abc", :target "foo"}] - :created_at true - :updated_at true} - [{:sizeX 2 - :sizeY 2 - :col 4 - :row 4 - :parameter_mappings [{:card-id 123, :hash "abc", :target "foo"}]}]] + [{:sizeX 2 + :sizeY 2 + :col 4 + :row 4 + :series [] + :parameter_mappings [{:card-id 123, :hash "abc", :target "foo"}] + :visualization_settings {} + :created_at true + :updated_at true} + [{:sizeX 2 + :sizeY 2 + :col 4 + :row 4 + :parameter_mappings [{:card-id 123, :hash "abc", :target "foo"}] + :visualization_settings {}}]] (tu/with-temp* [Dashboard [{dashboard-id :id}] Card [{card-id :id}]] - [(-> ((user->client :rasta) :post 200 (format "dashboard/%d/cards" dashboard-id) {:cardId card-id - :row 4 - :col 4 - :parameter_mappings [{:card-id 123, :hash "abc", :target "foo"}]}) + [(-> ((user->client :rasta) :post 200 (format "dashboard/%d/cards" dashboard-id) {:cardId card-id + :row 4 + :col 4 + :parameter_mappings [{:card-id 123, :hash "abc", :target "foo"}] + :visualization_settings {}}) (dissoc :id :dashboard_id :card_id) (update :created_at #(not (nil? %))) (update :updated_at #(not (nil? %)))) (map (partial into {}) - (db/select [DashboardCard :sizeX :sizeY :col :row :parameter_mappings], :dashboard_id dashboard-id))])) + (db/select [DashboardCard :sizeX :sizeY :col :row :parameter_mappings :visualization_settings], :dashboard_id dashboard-id))])) ;; new dashboard card w/ additional series (expect - [{:sizeX 2 - :sizeY 2 - :col 4 - :row 4 - :parameter_mappings [] - :series [{:name "Series Card" - :description nil - :display "table" - :dataset_query {} - :visualization_settings {}}] - :created_at true - :updated_at true} + [{:sizeX 2 + :sizeY 2 + :col 4 + :row 4 + :parameter_mappings [] + :visualization_settings {} + :series [{:name "Series Card" + :description nil + :display "table" + :dataset_query {} + :visualization_settings {}}] + :created_at true + :updated_at true} [{:sizeX 2 :sizeY 2 :col 4 @@ -260,43 +265,47 @@ ;; ## PUT /api/dashboard/:id/cards (expect - [[{:sizeX 2 - :sizeY 2 - :col nil - :row nil - :series [] - :parameter_mappings [] - :created_at true - :updated_at true} - {:sizeX 2 - :sizeY 2 - :col nil - :row nil - :parameter_mappings [] - :series [] - :created_at true - :updated_at true}] + [[{:sizeX 2 + :sizeY 2 + :col nil + :row nil + :series [] + :parameter_mappings [] + :visualization_settings {} + :created_at true + :updated_at true} + {:sizeX 2 + :sizeY 2 + :col nil + :row nil + :parameter_mappings [] + :visualization_settings {} + :series [] + :created_at true + :updated_at true}] {:status "ok"} - [{:sizeX 4 - :sizeY 2 - :col 0 - :row 0 - :parameter_mappings [] - :series [{:name "Series Card" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}}] - :created_at true - :updated_at true} - {:sizeX 1 - :sizeY 1 - :col 1 - :row 3 - :parameter_mappings [] - :series [] - :created_at true - :updated_at true}]] + [{:sizeX 4 + :sizeY 2 + :col 0 + :row 0 + :parameter_mappings [] + :visualization_settings {} + :series [{:name "Series Card" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}}] + :created_at true + :updated_at true} + {:sizeX 1 + :sizeY 1 + :col 1 + :row 3 + :parameter_mappings [] + :visualization_settings {} + :series [] + :created_at true + :updated_at true}]] ;; fetch a dashboard WITH a dashboard card on it (tu/with-temp* [Dashboard [{dashboard-id :id}] Card [{card-id :id}] diff --git a/test/metabase/models/dashboard_card_test.clj b/test/metabase/models/dashboard_card_test.clj index 019c0596a820991408f0a0d5168514b9a53c5329..d29c31ad6d2c920b5a7f0e68d093d23d5f699cc9 100644 --- a/test/metabase/models/dashboard_card_test.clj +++ b/test/metabase/models/dashboard_card_test.clj @@ -28,12 +28,13 @@ ;; retrieve-dashboard-card ;; basic dashcard (no additional series) (expect - {:sizeX 2 - :sizeY 2 - :col nil - :row nil - :parameter_mappings [{:foo "bar"}] - :series []} + {:sizeX 2 + :sizeY 2 + :col nil + :row nil + :parameter_mappings [{:foo "bar"}] + :visualization_settings {} + :series []} (tu/with-temp* [Dashboard [{dashboard-id :id}] Card [{card-id :id}] DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id, :card_id card-id, :parameter_mappings [{:foo "bar"}]}]] @@ -42,21 +43,22 @@ ;; retrieve-dashboard-card ;; dashcard w/ additional series (expect - {:sizeX 2 - :sizeY 2 - :col nil - :row nil - :parameter_mappings [] - :series [{:name "Additional Series Card 1" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}} - {:name "Additional Series Card 2" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}}]} + {:sizeX 2 + :sizeY 2 + :col nil + :row nil + :parameter_mappings [] + :visualization_settings {} + :series [{:name "Additional Series Card 1" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}} + {:name "Additional Series Card 2" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}}]} (tu/with-temp* [Dashboard [{dashboard-id :id}] Card [{card-id :id}] Card [{series-id-1 :id} {:name "Additional Series Card 1"}] @@ -95,37 +97,40 @@ ;; create-dashboard-card! ;; simple example with a single card (expect - [{:sizeX 4 - :sizeY 3 - :col 1 - :row 1 - :parameter_mappings [{:foo "bar"}] - :series [{:name "Test Card" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}}]} - {:sizeX 4 - :sizeY 3 - :col 1 - :row 1 - :parameter_mappings [{:foo "bar"}] - :series [{:name "Test Card" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}}]}] + [{:sizeX 4 + :sizeY 3 + :col 1 + :row 1 + :parameter_mappings [{:foo "bar"}] + :visualization_settings {} + :series [{:name "Test Card" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}}]} + {:sizeX 4 + :sizeY 3 + :col 1 + :row 1 + :parameter_mappings [{:foo "bar"}] + :visualization_settings {} + :series [{:name "Test Card" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}}]}] (tu/with-temp* [Dashboard [{dashboard-id :id}] Card [{card-id :id} {:name "Test Card"}]] - (let [dashboard-card (create-dashboard-card! {:creator_id (user->id :rasta) - :dashboard_id dashboard-id - :card_id card-id - :sizeX 4 - :sizeY 3 - :row 1 - :col 1 - :parameter_mappings [{:foo "bar"}] - :series [card-id]})] + (let [dashboard-card (create-dashboard-card! {:creator_id (user->id :rasta) + :dashboard_id dashboard-id + :card_id card-id + :sizeX 4 + :sizeY 3 + :row 1 + :col 1 + :parameter_mappings [{:foo "bar"}] + :visualization_settings {} + :series [card-id]})] ;; first result is return value from function, second is to validate db captured everything [(remove-ids-and-timestamps dashboard-card) (remove-ids-and-timestamps (retrieve-dashboard-card (:id dashboard-card)))]))) @@ -137,42 +142,45 @@ ;; 3. ensure the card_id cannot be changed ;; 4. ensure the dashboard_id cannot be changed (expect - [{:sizeX 2 - :sizeY 2 - :col nil - :row nil - :parameter_mappings [{:foo "bar"}] - :series []} - {:sizeX 4 - :sizeY 3 - :col 1 - :row 1 - :parameter_mappings [{:foo "barbar"}] - :series [{:name "Test Card 2" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}} - {:name "Test Card 1" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}}]} - {:sizeX 4 - :sizeY 3 - :col 1 - :row 1 - :parameter_mappings [{:foo "barbar"}] - :series [{:name "Test Card 2" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}} - {:name "Test Card 1" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}}]}] + [{:sizeX 2 + :sizeY 2 + :col nil + :row nil + :parameter_mappings [{:foo "bar"}] + :visualization_settings {} + :series []} + {:sizeX 4 + :sizeY 3 + :col 1 + :row 1 + :parameter_mappings [{:foo "barbar"}] + :visualization_settings {} + :series [{:name "Test Card 2" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}} + {:name "Test Card 1" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}}]} + {:sizeX 4 + :sizeY 3 + :col 1 + :row 1 + :parameter_mappings [{:foo "barbar"}] + :visualization_settings {} + :series [{:name "Test Card 2" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}} + {:name "Test Card 1" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}}]}] (tu/with-temp* [Dashboard [{dashboard-id :id}] Card [{card-id :id}] DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id, :card_id card-id, :parameter_mappings [{:foo "bar"}]}] @@ -182,14 +190,15 @@ ;; second is the return value from the update call ;; third is to validate db captured everything [(remove-ids-and-timestamps (retrieve-dashboard-card dashcard-id)) - (remove-ids-and-timestamps (update-dashboard-card! {:id dashcard-id - :actor_id (user->id :rasta) - :dashboard_id nil - :card_id nil - :sizeX 4 - :sizeY 3 - :row 1 - :col 1 - :parameter_mappings [{:foo "barbar"}] - :series [card-id-2 card-id-1]})) + (remove-ids-and-timestamps (update-dashboard-card! {:id dashcard-id + :actor_id (user->id :rasta) + :dashboard_id nil + :card_id nil + :sizeX 4 + :sizeY 3 + :row 1 + :col 1 + :parameter_mappings [{:foo "barbar"}] + :visualization_settings {} + :series [card-id-2 card-id-1]})) (remove-ids-and-timestamps (retrieve-dashboard-card dashcard-id))]))