diff --git a/package.json b/package.json index 826a3a93ed62ad0127ea32cb19a47da9bb1220a1..4fb6766940ab198c3da68b79dce4cf507a237338 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "dc": "2.0.0-beta.1", "fixed-data-table": "0.2.0", "humanize-plus": "1.5.0", - "inflection": "1.7.1", + "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 ac5686fe5e0f14651391be43eb7789b51b35a4a1..2f09cad568a7a9cfadfa463f67a0767c9416c366 100644 --- a/resources/frontend_client/app/card/card.controllers.js +++ b/resources/frontend_client/app/card/card.controllers.js @@ -129,6 +129,7 @@ CardControllers.controller('CardDetail', [ var headerModel = { card: null, + tableMetadata: null, cardApi: Card, dashboardApi: Dashboard, broadcastEventFn: function(eventName, value) { @@ -177,6 +178,9 @@ CardControllers.controller('CardDetail', [ setCard(card, { setDirty: true, replaceState: false }) }); }, + revertCardFn: function() { + revertCard(); + }, toggleDataReferenceFn: toggleDataReference, cardIsNewFn: cardIsNew, cardIsDirtyFn: cardIsDirty @@ -365,6 +369,7 @@ CardControllers.controller('CardDetail', [ function renderHeader() { // ensure rendering model is up to date headerModel.card = angular.copy(card); + headerModel.tableMetadata = tableMetadata; headerModel.isShowingDataReference = $scope.isShowingDataReference; React.render(<QueryHeader {...headerModel}/>, document.getElementById('react_qb_header')); @@ -844,6 +849,10 @@ CardControllers.controller('CardDetail', [ savedCardSerialized = null; } + function revertCard() { + loadAndSetCard(); + } + // needs to be performed asynchronously otherwise we get weird infinite recursion var updateUrl = _.debounce(function(replaceState) { var copy = cleanCopyCard(card); diff --git a/resources/frontend_client/app/css/edit.css b/resources/frontend_client/app/css/edit.css new file mode 100644 index 0000000000000000000000000000000000000000..a6e3dfbc05037de05def463c717e72a6e3ae021b --- /dev/null +++ b/resources/frontend_client/app/css/edit.css @@ -0,0 +1,42 @@ +.EditHeader { + background-color: #6CAFED; +} + +.EditHeader-title { + color: white; +} + +.EditHeader-subtitle { + color: rgba(255,255,255,0.5); +} + +.EditHeader .Button { + margin-left: 0.75em; +} + + +.EditHeader .Button { + color: var(--brand-color); + border: none; + text-transform: uppercase; + font-size: 0.75rem; + background-color: rgba(255,255,255,0.5); + font-weight: normal; +} + +.EditHeader .Button--primary { + background-color: white; +} + +.EditHeader .Button:hover { + background-color: white; +} + + +.EditHeader .Button.Button--primary:hover { + background-color: var(--brand-color); +} + +.EditTitle { + width: 455px; +} diff --git a/resources/frontend_client/app/lib/query.js b/resources/frontend_client/app/lib/query.js index 2bad68608393a83ca1627ee4b3f79c4d51b06e30..c7586e62b9276ba70fae92aff25d126c1953f689 100644 --- a/resources/frontend_client/app/lib/query.js +++ b/resources/frontend_client/app/lib/query.js @@ -1,5 +1,7 @@ 'use strict'; +import inflection from "inflection"; + var Query = { canRun: function(query) { @@ -305,6 +307,68 @@ var Query = { }).filter((r) => r.fields.length > 0); } return results; + }, + + generateQueryDescription: function(dataset_query, tableMetadata) { + if (!tableMetadata) { + return ""; + } + + function getFieldName(id, table) { + if (Array.isArray(id)) { + if (id[0] === "fk->") { + var field = table.fields_lookup[id[1]]; + if (field) { + return field.display_name + " " + getFieldName(id[2], field.target.table); + } + } + } else if (table.fields_lookup[id]) { + return table.fields_lookup[id].display_name + } + return '[unknown]'; + } + + function getFilterDescription(filter) { + if (filter[0] === "AND" || filter[0] === "OR") { + return filter.slice(1).map(getFilterDescription).join(" " + filter[0].toLowerCase() + " "); + } else { + return getFieldName(filter[1], tableMetadata); + } + } + + var query = dataset_query.query; + + var name = inflection.pluralize(tableMetadata.display_name) + " "; + + switch (query.aggregation[0]) { + case "rows": name += "raw data"; break; + case "count": name += "count"; break; + case "avg": name += "average of " + getFieldName(query.aggregation[1], tableMetadata); break; + case "distinct": name += "distinct values of " + getFieldName(query.aggregation[1], tableMetadata); break; + case "stddev": name += "standard deviation of " + getFieldName(query.aggregation[1], tableMetadata); break; + case "sum": name += "sum of " + getFieldName(query.aggregation[1], tableMetadata); break; + case "cum_sum": name += "cumulative sum of " + getFieldName(query.aggregation[1], tableMetadata); break; + default: + } + + if (query.breakout && query.breakout.length > 0) { + name += ", grouped by " + query.breakout.map((b) => getFieldName(b, tableMetadata)).join(" and "); + } + + var filters = Query.getFilters(dataset_query.query); + if (filters && filters.length > 0) { + name += ", filtered by " + getFilterDescription(filters); + } + + if (query.order_by && query.order_by.length > 0) { + name += ", sorted by " + query.order_by.map((ordering) => getFieldName(ordering[0], tableMetadata) + " " + ordering[1]).join(" and "); + } + + if (query.limit != null) { + name += ", " + query.limit + " " + inflection.inflect("row", query.limit); + } + + return name; } } diff --git a/resources/frontend_client/app/query_builder/header.react.js b/resources/frontend_client/app/query_builder/header.react.js index 9b1cbe1aaaffa470be22d3e7facdd44d80f7158c..b37085c120847009bd1e3e86e1898cfef8f8af75 100644 --- a/resources/frontend_client/app/query_builder/header.react.js +++ b/resources/frontend_client/app/query_builder/header.react.js @@ -8,6 +8,8 @@ import Icon from './icon.react'; import QueryModeToggle from './query_mode_toggle.react'; import Saver from './saver.react'; +import Input from '../admin/metadata/components/Input.react'; + var cx = React.addons.classSet; var ReactCSSTransitionGroup = React.addons.CSSTransitionGroup; @@ -15,9 +17,11 @@ export default React.createClass({ displayName: 'QueryHeader', propTypes: { card: React.PropTypes.object.isRequired, + tableMetadata: React.PropTypes.object, // can't be required, sometimes null cardApi: React.PropTypes.func.isRequired, dashboardApi: React.PropTypes.func.isRequired, notifyCardChangedFn: React.PropTypes.func.isRequired, + revertCardFn: React.PropTypes.func.isRequired, setQueryModeFn: React.PropTypes.func.isRequired, isShowingDataReference: React.PropTypes.bool.isRequired, toggleDataReferenceFn: React.PropTypes.func.isRequired, @@ -104,20 +108,57 @@ export default React.createClass({ } }, - render: function() { - var title = this.props.card.name || "New question"; + renderEditHeader: function() { + if (!this.props.cardIsNewFn()) { + var updateButton, discardButton; + if (this.props.cardIsDirtyFn()) { + discardButton = <a className="Button Button--small text-uppercase" href="#" onClick={this.props.revertCardFn}>Discard Changes</a>; + } + if (this.state.recentlySaved === "updated" || (this.props.cardIsDirtyFn() && this.props.card.is_creator)) { + updateButton = ( + <ActionButton + actionFn={this.save} + className='Button Button--small Button--primary text-uppercase' + normalText="Update" + activeText="Updating…" + failedText="Update failed" + successText="Updated" + /> + ); + } + return ( + <div className="EditHeader p1 px3 flex align-center"> + <span className="EditHeader-title">You are editing a saved question.</span> + <span className="EditHeader-subtitle mx1">Changes will be reflected in 1 dashboard and can be reverted.</span> + <span className="flex-align-right"> + {updateButton} + {discardButton} + <a className="Button Button--small text-uppercase" href="#">Delete</a> + </span> + </div> + ); + } + }, + + setCardAttribute: function(attribute, event) { + this.props.card[attribute] = event.target.value; + this.props.notifyCardChangedFn(this.props.card); + }, - var editButton; + render: function() { + var titleAndDescription; if (!this.props.cardIsNewFn() && this.props.card.is_creator) { - editButton = ( - <Saver - card={this.props.card} - saveFn={this.props.notifyCardChangedFn} - saveButtonText="Update" - className='inline-block ml1 link' - canDelete={this.props.card.is_creator} - deleteFn={this.deleteCard} - /> + titleAndDescription = ( + <div className="EditTitle flex flex-column flex-full bordered rounded mt1 mb2"> + <Input className="AdminInput text-bold border-bottom rounded-top h3" type="text" value={this.props.card.name} onChange={this.setCardAttribute.bind(null, "name")}/> + <Input className="AdminInput rounded-bottom h4" type="text" value={this.props.card.description} onChange={this.setCardAttribute.bind(null, "description")} placeholder="No description yet" /> + </div> + ); + } else { + titleAndDescription = ( + <div className="flex align-center"> + <h1 className="Entity-title">New question</h1> + </div> ); } @@ -127,20 +168,13 @@ export default React.createClass({ saveButton = ( <Saver card={this.props.card} + tableMetadata={this.props.tableMetadata} saveFn={this.saveCard} buttonText="Save" saveButtonText="Save" canDelete={false} /> ); - } else if (this.state.recentlySaved === "updated" || (this.props.cardIsDirtyFn() && this.props.card.is_creator)) { - // for existing cards we render a very simply ActionButton - saveButton = ( - <ActionButton - actionFn={this.save} - className='Button Button--primary' - /> - ); } var cloneButton; @@ -192,8 +226,7 @@ export default React.createClass({ ); var attribution; - - if(this.props.card.creator) { + if(this.props.card.creator && false) { attribution = ( <div className="Entity-attribution"> Asked by {this.props.card.creator.common_name} @@ -210,30 +243,29 @@ export default React.createClass({ } return ( - <div className="py1 lg-py2 xl-py3 QueryBuilder-section wrapper flex align-center"> - <div className="Entity"> - <div className="flex align-center"> - <h1 className="Entity-title">{title}</h1> - {this.permissions()} - {editButton} + <div> + {this.renderEditHeader()} + <div className="py1 lg-py2 xl-py3 QueryBuilder-section wrapper flex align-center"> + <div className="Entity"> + {titleAndDescription} + {attribution} </div> - {attribution} - </div> - <div className="flex align-center flex-align-right"> + <div className="flex align-center flex-align-right"> - <span className="pr3"> - {saveButton} - {queryModeToggle} - </span> + <span className="pr3"> + {saveButton} + {queryModeToggle} + </span> - {cardFavorite} - {cloneButton} - {addToDashButton} + {cardFavorite} + {cloneButton} + {addToDashButton} - {dividerRight} + {dividerRight} - {dataReferenceButton} + {dataReferenceButton} + </div> </div> </div> ); diff --git a/resources/frontend_client/app/query_builder/saver.react.js b/resources/frontend_client/app/query_builder/saver.react.js index d162ff1d2f5229a86bbef09b8f44cf7e5eeb12e7..18051f6feaef8c5f5bfd3f1dc2a9ff51c04fcf6a 100644 --- a/resources/frontend_client/app/query_builder/saver.react.js +++ b/resources/frontend_client/app/query_builder/saver.react.js @@ -5,12 +5,15 @@ import OnClickOutside from 'react-onclickoutside'; import FormField from './form_field.react'; import Icon from './icon.react'; +import Query from "metabase/lib/query"; + var cx = React.addons.classSet; export default React.createClass({ displayName: 'Saver', propTypes: { card: React.PropTypes.object.isRequired, + tableMetadata: React.PropTypes.object, // can't be required, sometimes null saveFn: React.PropTypes.func.isRequired, deleteFn: React.PropTypes.func }, @@ -72,7 +75,7 @@ export default React.createClass({ var card = this.props.card; card.name = this.refs.name.getDOMNode().value.trim(); card.description = this.refs.description.getDOMNode().value.trim(); - card.public_perms = parseInt(this.refs.public_perms.getDOMNode().value); + card.public_perms = 2; // public read/write this.props.saveFn(card).then((success) => { if (this.isMounted()) { @@ -109,12 +112,6 @@ export default React.createClass({ return false; } - // TODO: hard coding values :( - var privacyOptions = [ - (<option key="0" value={0}>Private</option>), - (<option key="1" value={1}>Public (others can read)</option>) - ]; - var formError; if (this.state.errors) { var errorMessage; @@ -140,6 +137,8 @@ export default React.createClass({ "Button--primary": this.isFormReady() }); + var name = this.props.card.name || Query.generateQueryDescription(this.props.card.dataset_query, this.props.tableMetadata); + return ( <form className="NewForm full" onSubmit={this.save}> <div className="Form-header flex align-center"> @@ -154,25 +153,14 @@ export default React.createClass({ displayName="Name" fieldName="name" errors={this.state.errors}> - <input ref="name" className="Form-input full" name="name" placeholder="What is the name of your card?" defaultValue={this.props.card.name} autofocus/> + <input ref="name" className="Form-input full" name="name" placeholder="What is the name of your card?" defaultValue={name} autofocus/> </FormField> <FormField displayName="Description (optional)" fieldName="description" errors={this.state.errors}> - <input ref="description" className="Form-input full" name="description" placeholder="What else should people know about this?" defaultValue={this.props.card.description} /> - </FormField> - - <FormField - displayName="Privacy" - fieldName="public_perms" - errors={this.state.errors}> - <label className="Select"> - <select className="mt1" ref="public_perms" defaultValue={this.props.card.public_perms}> - {privacyOptions} - </select> - </label> + <textarea ref="description" className="Form-input full" name="description" placeholder="It's optional but oh, so helpful" defaultValue={this.props.card.description} /> </FormField> {this.renderCardDelete()}