From b73f2893fd20b338c8327ce68390638a87a5d95e Mon Sep 17 00:00:00 2001 From: Tom Robinson <tlrobinson@gmail.com> Date: Fri, 10 Jul 2015 02:28:03 -0700 Subject: [PATCH] Basically working Data Reference panel --- package.json | 1 + .../app/card/card.controllers.js | 28 +++++- .../app/card/partials/card_detail.html | 13 ++- .../app/components/buttons/buttons.css | 26 ++++++ .../frontend_client/app/css/core/bordered.css | 4 + .../frontend_client/app/css/core/colors.css | 4 + .../frontend_client/app/css/query_builder.css | 19 ++++ .../query_builder/add_to_dashboard.react.js | 6 -- .../app/query_builder/data_reference.react.js | 85 ++++++++++++++++++ .../data_reference_field.react.js | 89 +++++++++++++++++++ .../data_reference_main.react.js | 73 +++++++++++++++ .../data_reference_table.react.js | 84 +++++++++++++++++ .../app/query_builder/header.react.js | 52 +++++++++-- 13 files changed, 466 insertions(+), 18 deletions(-) create mode 100644 resources/frontend_client/app/query_builder/data_reference.react.js create mode 100644 resources/frontend_client/app/query_builder/data_reference_field.react.js create mode 100644 resources/frontend_client/app/query_builder/data_reference_main.react.js create mode 100644 resources/frontend_client/app/query_builder/data_reference_table.react.js diff --git a/package.json b/package.json index 86da851d6ac..1b02dce044e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "d3-tip": "^0.6.7", "dc": "2.0.0-beta.1", "fixed-data-table": "0.2.0", + "inflection": "^1.7.1", "jquery": "2.1.4", "moment": "2.9.0", "ng-sortable": "1.2.0", diff --git a/resources/frontend_client/app/card/card.controllers.js b/resources/frontend_client/app/card/card.controllers.js index 11f6cb16152..41db8953551 100644 --- a/resources/frontend_client/app/card/card.controllers.js +++ b/resources/frontend_client/app/card/card.controllers.js @@ -1,6 +1,7 @@ 'use strict'; /*global _, document, confirm*/ +import DataReference from '../query_builder/data_reference.react'; import GuiQueryEditor from '../query_builder/gui_query_editor.react'; import NativeQueryEditor from '../query_builder/native_query_editor.react'; import QueryHeader from '../query_builder/header.react'; @@ -90,6 +91,8 @@ CardControllers.controller('CardDetail', [ } }; + $scope.isShowingReference = true; + var queryResult = null, databases = null, tables = null, @@ -152,7 +155,8 @@ CardControllers.controller('CardDetail', [ }, cloneCardFn: function(cardId) { $scope.$apply(() => $location.url('/card/create?clone='+cardId)); - } + }, + toggleReference: toggleReference }; var editorModel = { @@ -420,6 +424,15 @@ CardControllers.controller('CardDetail', [ } }; + var dataReferenceModel = { + Metabase: Metabase, + closeFn: toggleReference, + notifyQueryModifiedFn: function(dataset_query) { + // we are being told that the query has been modified + card.dataset_query = dataset_query; + renderAll(); + }, + }; // ===== REACT render functions @@ -464,10 +477,18 @@ CardControllers.controller('CardDetail', [ React.render(<QueryVisualization {...visualizationModel}/>, document.getElementById('react_qb_viz')); } + function renderDataReference() { + dataReferenceModel.databases = databases; + dataReferenceModel.tableMetadata = tableMetadata; + dataReferenceModel.query = card.dataset_query; + React.render(<DataReference {...dataReferenceModel}/>, document.getElementById('react_data_reference')); + } + function renderAll() { renderHeader(); renderEditor(); renderVisualization(); + renderDataReference(); } @@ -521,6 +542,11 @@ CardControllers.controller('CardDetail', [ return QueryUtils.populateQueryOptions(updatedTable); } + function toggleReference() { + $scope.isShowingReference = !$scope.isShowingReference; + $scope.$digest(); + } + function resetCardQuery(mode) { var queryTemplate = angular.copy(newQueryTemplates[mode]); if (queryTemplate) { diff --git a/resources/frontend_client/app/card/partials/card_detail.html b/resources/frontend_client/app/card/partials/card_detail.html index 8c661708787..0fc62334b9d 100644 --- a/resources/frontend_client/app/card/partials/card_detail.html +++ b/resources/frontend_client/app/card/partials/card_detail.html @@ -1,5 +1,10 @@ -<div class="QueryBuilder mt2"> - <div id="react_qb_header"></div> - <div id="react_qb_editor"></div> - <div class="QueryVisualization" id="react_qb_viz"></div> +<div class="flex flex-row full-height"> + <div class="QueryBuilder mt2 flex-full"> + <div id="react_qb_header"></div> + <div id="react_qb_editor"></div> + <div class="QueryVisualization" id="react_qb_viz"></div> + </div> + <div class="DataReference" id="react_data_reference" ng-class="{ 'DataReference--visible': isShowingReference }"> + reference + </div> </div> diff --git a/resources/frontend_client/app/components/buttons/buttons.css b/resources/frontend_client/app/components/buttons/buttons.css index 1237bff361e..90d9b96f819 100644 --- a/resources/frontend_client/app/components/buttons/buttons.css +++ b/resources/frontend_client/app/components/buttons/buttons.css @@ -42,6 +42,11 @@ transition: border .3s linear; } +.Button--small { + padding: 0.4rem 0.75rem; + font-size: 0.6rem; +} + .Button--primary { color: #fff; background: var(--primary-button-bg-color); @@ -58,6 +63,12 @@ border-radius: 99px; } +.Button--white { + background-color: white; + color: color(var(--base-grey) shade(30%)) !important; + border-color: color(var(--base-grey) shade(30%)); +} + .Button-group { display: inline-block; border-radius: var(--default-button-border-radius); @@ -98,6 +109,21 @@ color: rgb(74,144,226); } +.Button-group--brand { + border-color: white; +} + +.Button-group--brand .Button { + border-color: white; + color: var(--brand-color); + background-color: #E5E5E5; +} + +.Button-group--brand .Button--active { + background-color: var(--brand-color); + color: white; +} + .Button:disabled { opacity: 0.5; cursor: not-allowed; diff --git a/resources/frontend_client/app/css/core/bordered.css b/resources/frontend_client/app/css/core/bordered.css index af71d2b0513..4483ce8bc94 100644 --- a/resources/frontend_client/app/css/core/bordered.css +++ b/resources/frontend_client/app/css/core/bordered.css @@ -38,6 +38,10 @@ border-color: rgba(255,255,255,0.2) !important; } +.border-dark { + border-color: rgba(0,0,0,0.2) !important; +} + /* BORDERLESS IS THE DEFAULT */ /* ONLY USE IF needing to override an existing border! */ /* ensure there is no border via important */ diff --git a/resources/frontend_client/app/css/core/colors.css b/resources/frontend_client/app/css/core/colors.css index 3948ca7f82f..a2ec0aa38ba 100644 --- a/resources/frontend_client/app/css/core/colors.css +++ b/resources/frontend_client/app/css/core/colors.css @@ -13,6 +13,10 @@ --error-color: #EF8C8C; } +.text-default { + color: var(--default-font-color) !important; +} + /* white */ .text-white, .text-white-hover:hover { color: #fff; } diff --git a/resources/frontend_client/app/css/query_builder.css b/resources/frontend_client/app/css/query_builder.css index f2974576959..4ac503667ca 100644 --- a/resources/frontend_client/app/css/query_builder.css +++ b/resources/frontend_client/app/css/query_builder.css @@ -370,3 +370,22 @@ .ChartType--notSensible { opacity: 0.5; } + +.DataReference { + width: 0px; + background-color: #F9FBFC; + transition: width 0.5s; + overflow: hidden; +} + +.DataReference--visible { + width: 300px; +} + +.DataReference-container { + width: 300px; +} + +.DataReference h1 { + font-size: 20pt; +} diff --git a/resources/frontend_client/app/query_builder/add_to_dashboard.react.js b/resources/frontend_client/app/query_builder/add_to_dashboard.react.js index 9b61b6508f8..176080137d6 100644 --- a/resources/frontend_client/app/query_builder/add_to_dashboard.react.js +++ b/resources/frontend_client/app/query_builder/add_to_dashboard.react.js @@ -47,12 +47,6 @@ export default React.createClass({ } }, render: function () { - // if we don't have a saved card then don't render anything - // TODO: we should probably do this in the header - if (this.props.card.id === undefined) { - return false; - } - // TODO: if our card is dirty should we disable this button? // ex: someone modifies a query but hasn't run/save the change? return ( diff --git a/resources/frontend_client/app/query_builder/data_reference.react.js b/resources/frontend_client/app/query_builder/data_reference.react.js new file mode 100644 index 00000000000..8b7a195953f --- /dev/null +++ b/resources/frontend_client/app/query_builder/data_reference.react.js @@ -0,0 +1,85 @@ +'use strict'; + +import DataReferenceMain from './data_reference_main.react'; +import DataReferenceTable from './data_reference_table.react'; +import DataReferenceField from './data_reference_field.react'; +import Icon from './icon.react'; +import inflection from 'inflection'; + +var cx = React.addons.classSet; + +export default React.createClass({ + displayName: 'DataReference', + + getInitialState: function() { + return { + stack: [],//{ type: "table", table: {id: 7} }], + tables: {}, + fields: {} + }; + }, + + close: function() { + this.props.closeFn(); + }, + + back: function() { + this.setState({ + stack: this.state.stack.slice(0, -1) + }); + }, + + showField: function(field) { + this.setState({ + stack: this.state.stack.concat({ type: "field", field: field }) + }); + }, + + showTable: function(table) { + this.setState({ + stack: this.state.stack.concat({ type: "table", table: table }) + }); + }, + + render: function() { + var content; + if (this.state.stack.length === 0) { + content = <DataReferenceMain {...this.props} showTable={this.showTable} /> + } else { + var page = this.state.stack[this.state.stack.length - 1]; + if (page.type === "table") { + content = <DataReferenceTable {...this.props} table={page.table} showField={this.showField} /> + } else if (page.type === "field") { + content = <DataReferenceField {...this.props} field={page.field}/> + } + } + + var backButton; + if (this.state.stack.length > 0) { + backButton = ( + <a href="#" className="flex align-center mb2 text-default no-decoration" onClick={this.back}> + <Icon name="chevronleft" width="18px" height="18px" /> + <span className="text-uppercase">Back</span> + </a> + ) + } + + var closeButton = ( + <a href="#" className="flex-align-right text-default no-decoration" onClick={this.close}> + <Icon name="close" width="18px" height="18px" /> + </a> + ); + + return ( + <div className="DataReference-container p3"> + <div className="DataReference-header flex mb1"> + {backButton} + {closeButton} + </div> + <div className="DataReference-content"> + {content} + </div> + </div> + ); + } +}); diff --git a/resources/frontend_client/app/query_builder/data_reference_field.react.js b/resources/frontend_client/app/query_builder/data_reference_field.react.js new file mode 100644 index 00000000000..61e2687cd9d --- /dev/null +++ b/resources/frontend_client/app/query_builder/data_reference_field.react.js @@ -0,0 +1,89 @@ +'use strict'; + +import Icon from './icon.react'; +import inflection from 'inflection'; + +import Query from './query'; + +var cx = React.addons.classSet; + +export default React.createClass({ + displayName: 'DataReferenceField', + + getInitialState: function() { + return { + table: undefined + }; + }, + + componentWillMount: function() { + this.props.Metabase.table_query_metadata({ + 'tableId': this.props.field.table_id + }).$promise.then((table) => { + this.setState({ table: table }); + }); + }, + + filterBy: function() { + var query = this.props.query; + this.setDatabaseAndTable(); + Query.addFilter(query.query); + Query.updateFilter(query.query, Query.getFilters(query.query).length - 1, [null, this.props.field.id, null]); + this.props.notifyQueryModifiedFn(query); + }, + + groupBy: function() { + var query = this.props.query; + this.setDatabaseAndTable(); + if (!Query.hasValidAggregation(query.query)) { + Query.updateAggregation(query.query, ["rows"]); + } + Query.addDimension(query.query); + Query.updateDimension(query.query, this.props.field.id, query.query.breakout.length - 1); + this.props.notifyQueryModifiedFn(query); + }, + + setDatabaseAndTable: function() { + var query = this.props.query; + if (query.database == undefined && this.table) { + query.database = this.state.table.db_id; + } + if (query.query.source_table == undefined) { + query.database = this.props.field.table_id; + } + }, + + renderFilterByButton: function() { + var query = this.props.query; + if (query.database == undefined) { + + } + }, + + render: function() { + console.log(this.props, this.state) + var name = inflection.humanize(this.props.field.name); + return ( + <div> + <h1>{name}</h1> + <p>{this.props.field.description}</p> + <p className="text-bold">Use for current question</p> + <ul className="my2"> + <li className="mt1"> + <a className="Button Button--white text-default no-decoration" href="#" onClick={this.filterBy}> + <Icon className="mr1" name="add" width="12px" height="12px"/> Filter by {name} + </a> + </li> + <li className="mt1"> + <a className="Button Button--white text-default no-decoration" href="#" onClick={this.groupBy}> + <Icon className="mr2" name="add" width="12px" height="12px" /> Group by {name} + </a> + </li> + </ul> + {this.state.table&&this.state.table.name} + <p className="text-bold">Potentially useful questions</p> + <ul></ul> + </div> + ); + }, +}) diff --git a/resources/frontend_client/app/query_builder/data_reference_main.react.js b/resources/frontend_client/app/query_builder/data_reference_main.react.js new file mode 100644 index 00000000000..24a248655b3 --- /dev/null +++ b/resources/frontend_client/app/query_builder/data_reference_main.react.js @@ -0,0 +1,73 @@ +'use strict'; + +import Icon from './icon.react'; +import inflection from 'inflection'; + +var cx = React.addons.classSet; + +export default React.createClass({ + displayName: 'DataReferenceMain', + + getInitialState: function() { + return { + databases: {}, + tables: {} + }; + }, + + render: function() { + var databases; + if (this.props.databases) { + databases = this.props.databases.map((database) => { + var dbTables = this.state.databases[database.id]; + if (dbTables === undefined) { + this.state.databases[database.id] = null; // null indicates loading + this.props.Metabase.db_tables({ + 'dbId': database.id + }).$promise.then((db) => { + this.state.databases[database.id] = db; + this.setState({ databases: this.state.databases }); + }); + } + var tables; + var tableCount; + if (dbTables && dbTables.length !== undefined) { + tableCount = dbTables.length + " " + inflection.inflect("table", dbTables.length); + tables = dbTables.map((table, index) => { + var tableName = inflection.humanize(table.name); + var classes = cx({ + 'p1' : true, + 'border-bottom': index !== dbTables.length - 1, + 'bg-error': this.props.query.query.source_table === table.id + }) + return ( + <li className={classes}> + <a className="text-brand no-decoration" href="#" onClick={this.props.showTable.bind(null, table)}>{tableName}</a> + </li> + ); + }); + } + var classes = cx({ 'bg-success': this.props.query.database === database.id }); + return ( + <li className={classes}> + <div className="my2"> + <h2 className="inline-block">{database.name}</h2> + <span className="ml1">{tableCount}</span> + </div> + <ul>{tables}</ul> + </li> + ); + }); + } + + return ( + <div> + <h1>Data Reference</h1> + <p>Learn more about your data structure to
 ask more useful questions.</p> + <ul> + {databases} + </ul> + </div> + ); + }, +}) diff --git a/resources/frontend_client/app/query_builder/data_reference_table.react.js b/resources/frontend_client/app/query_builder/data_reference_table.react.js new file mode 100644 index 00000000000..94a15001cf6 --- /dev/null +++ b/resources/frontend_client/app/query_builder/data_reference_table.react.js @@ -0,0 +1,84 @@ +'use strict'; + +import Icon from './icon.react'; +import inflection from 'inflection'; + +var cx = React.addons.classSet; + +export default React.createClass({ + displayName: 'DataReferenceTable', + + getInitialState: function() { + return { + table: undefined, + pane: "fields" + }; + }, + + showPane: function(name) { + this.setState({ pane: name }); + }, + + render: function(page) { + var table = this.state.table; + if (table === undefined) { + this.state.table = null; // null indicates loading + this.props.Metabase.table_query_metadata({ + 'tableId': this.props.table.id + }).$promise.then((table) => { + this.setState({ table: table }); + }); + } + if (table) { + var name = inflection.humanize(table.name); + var rowStats; + if (table.rows != null) { + var words = inflection.humanize(table.name, true).split(" "); + words.push(inflection.inflect(words.pop(), table.rows)); // inflect the last word + rowStats = <p>There {table.rows === 1 ? "is" : "are"} {table.rows} {words.join(" ")}</p> + } + var fieldCount = table.fields.length + " " + inflection.inflect("field", table.fields.length); + var panes = { + "fields": fieldCount, + "metrics": "0 Metrics", + "connections": "O Connections" + }; + var tabs = Object.keys(panes).map((name) => { + var classes = cx({ + 'Button': true, + 'Button--small': true, + 'Button--active': name === this.state.pane + }); + return <a key={name} className={classes} href="#" onClick={this.showPane.bind(null, name)}>{panes[name]}</a> + }); + + var pane; + if (this.state.pane === "fields") { + var fields = table.fields.map((field, index) => { + var classes = cx({ 'p1' : true, 'border-bottom': index !== table.fields.length - 1 }) + var name = inflection.humanize(field.name); + return ( + <li key={field.id} className={classes}> + <a className="text-brand no-decoration" href="#" onClick={this.props.showField.bind(null, field)}>{name}</a> + </li> + ); + }); + pane = <ul>{fields}</ul>; + } + + return ( + <div> + <h1>{name}</h1> + <p>{table.description}</p> + {rowStats} + <div className="Button-group Button-group--brand text-uppercase"> + {tabs} + </div> + {pane} + </div> + ); + } else { + return null; + } + } +}) diff --git a/resources/frontend_client/app/query_builder/header.react.js b/resources/frontend_client/app/query_builder/header.react.js index 2142d892c6c..7684752ad7a 100644 --- a/resources/frontend_client/app/query_builder/header.react.js +++ b/resources/frontend_client/app/query_builder/header.react.js @@ -20,6 +20,7 @@ export default React.createClass({ notifyCardChangedFn: React.PropTypes.func.isRequired, setQueryModeFn: React.PropTypes.func.isRequired, downloadLink: React.PropTypes.string, + toggleReference: React.PropTypes.func.isRequired, }, getInitialState: function() { @@ -130,6 +131,10 @@ export default React.createClass({ }); }, + toggleReference: function() { + this.props.toggleReference(); + }, + permissions: function() { var permission; if(this.props.card.public_perms) { @@ -225,7 +230,22 @@ export default React.createClass({ cardFavorite = (<CardFavoriteButton cardApi={this.props.cardApi} cardId={this.props.card.id}></CardFavoriteButton>); } + var addToDashButton; + if (this.props.card.id != undefined) { + addToDashButton = ( + <AddToDashboard + card={this.props.card} + dashboardApi={this.props.dashboardApi} + broadcastEventFn={this.props.broadcastEventFn} + /> + ) + } + var dataReferenceButton = ( + <span className="mx1 text-grey-4 text-brand-hover"> + <Icon name='reference' onClick={this.toggleReference}></Icon> + </span> + ); var attribution; @@ -234,7 +254,21 @@ export default React.createClass({ <div className="Entity-attribution"> Asked by {this.props.card.creator.common_name} </div> - ) + ); + } + + var hasLeft = !!downloadButton; + var hasMiddle = !!(cardFavorite || cloneButton || addToDashButton); + var hasRight = !!dataReferenceButton; + + var dividerLeft; + if (hasLeft && (hasMiddle || hasRight)) { + dividerLeft = <div className="border-right border-dark"> </div> + } + + var dividerRight; + if (hasRight && hasMiddle) { + dividerRight = <div className="border-right border-dark"> </div> } return ( @@ -249,14 +283,18 @@ export default React.createClass({ </div> <div className="QueryHeader-actions flex-align-right"> - {cloneButton} {downloadButton} + + {dividerLeft} + {cardFavorite} - <AddToDashboard - card={this.props.card} - dashboardApi={this.props.dashboardApi} - broadcastEventFn={this.props.broadcastEventFn} - /> + {cloneButton} + {addToDashButton} + + {dividerRight} + + {dataReferenceButton} + {saveButton} {queryModeToggle} </div> -- GitLab