Skip to content
Snippets Groups Projects
Commit e7f83cfa authored by Allen Gilliland's avatar Allen Gilliland
Browse files

rework our angular controller, react components, and react models into a new...

rework our angular controller, react components, and react models into a new structure that has better encapsulation and lets each component manage itself with more autonomy.
parent 1e62983e
No related branches found
No related tags found
No related merge requests found
......@@ -84,7 +84,6 @@ CardControllers.controller('CardDetail', [
lighter weight and focused on communicating with the react app
*/
var MAX_DIMENSIONS = 2;
var newQueryTemplates = {
"query": {
......@@ -103,375 +102,192 @@ CardControllers.controller('CardDetail', [
}
};
var queryBuilder;
var createQueryBuilderModel = function (org) {
return {
org: org,
// GLOBAL
getDatabaseList: function() {
Metabase.db_list({
'orgId': org.id
}, function(dbs) {
queryBuilder.database_list = dbs;
// set the database to the first db, the user will be able to change it
// TODO be smarter about this and use the most recent or popular db
queryBuilder.setDatabase(dbs[0].id);
}, function(error) {
console.log('error getting database list', error);
});
},
// QUERY EDIT
setDatabase: function(databaseId) {
// check if this is the same db or not
if (databaseId != queryBuilder.card.dataset_query.database) {
queryBuilder.resetQuery();
queryBuilder.card.dataset_query.database = databaseId;
queryBuilder.getTables(databaseId);
queryBuilder.inform();
} else {
return false;
}
},
resetQuery: function() {
// TODO: 'native' query support
queryBuilder.card.dataset_query = {
type: "query",
query: {
aggregation: [null],
breakout: [],
filter: []
}
};
},
setPermissions: function(permission) {
queryBuilder.card.public_perms = permission;
queryBuilder.inform();
},
getTableFields: function(tableId) {
Metabase.table_query_metadata({
'tableId': tableId
}, function(result) {
console.log('result', result);
// Decorate with valid operators
var table = CorvusFormGenerator.addValidOperatorsToFields(result);
table = QueryUtils.populateQueryOptions(table);
queryBuilder.selected_table_fields = table;
// TODO: 'native' query support
if (queryBuilder.card.dataset_query.query.aggregation.length > 1) {
queryBuilder.getAggregationFields(queryBuilder.card.dataset_query.query.aggregation[0]);
} else {
queryBuilder.inform();
}
});
},
getTables: function(databaseId) {
Metabase.db_tables({
'dbId': databaseId
}, function(tables) {
queryBuilder.table_list = tables;
queryBuilder.inform();
// TODO(@kdoh) what are we actually doing with this?
}, function(error) {
console.log('error getting tables', error);
// ===== Controller local objects
var cardIsDirty = function() {
return false;
};
var card = {
name: null,
public_perms: 0,
display: "table",
dataset_query: null,
isDirty: cardIsDirty
};
// ===== REACT component models
var headerModel = {
card: card,
saveFn: function(settings) {
card.name = settings.name;
card.description = settings.description;
// TODO: set permissions here
if (card.id !== undefined) {
Card.update(card, function (updatedCard) {
// TODO: any reason to overwrite card and re-render?
}, function (error) {
console.log('error updating card', error);
});
},
canAddDimensions: function() {
// TODO: 'native' query support
var canAdd = queryBuilder.card.dataset_query.query.breakout.length < MAX_DIMENSIONS ? true : false;
return canAdd;
},
// a simple funciton to call when updating parts of the query. this allows us to know whether the query is 'dirty' and triggers
// a re-render of the react ui
inform: function() {
queryBuilder.hasChanged = true;
React.render(new QueryBuilder({
model: queryBuilder
}), document.getElementById('react'));
},
extractQuery: function(card) {
queryBuilder.card = card;
queryBuilder.getTables(card.dataset_query.database);
// TODO: 'native' query support
queryBuilder.setSourceTable(card.dataset_query.query.source_table);
},
getAggregationFields: function(aggregation) {
// @aggregation: id
// todo - this could be a war crime
// TODO: 'native' query support
_.map(queryBuilder.selected_table_fields.aggregation_options, function(option) {
if (option.short === aggregation) {
if (option.fields.length > 0) {
if (queryBuilder.card.dataset_query.query.aggregation.length == 1) {
queryBuilder.card.dataset_query.query.aggregation[1] = null;
}
queryBuilder.aggregation_field_list = option.fields;
queryBuilder.inform();
} else {
queryBuilder.card.dataset_query.query.aggregation.splice(1, 1);
queryBuilder.inform();
}
}
} else {
// set the organization
card.organization = $scope.currentOrg.id;
Card.create(card, function (newCard) {
$location.path('/' + $scope.currentOrg.slug + '/card/' + newCard.id);
}, function (error) {
console.log('error creating card', error);
});
},
setQueryMode: function(mode) {
var queryTemplate = newQueryTemplates[mode];
if (queryTemplate) {
queryBuilder.card.dataset_query = queryTemplate;
// TODO: should we carry over database here?
queryBuilder.inform();
}
},
setSourceTable: function(sourceTable) {
// this will either be the id or an object with an id
var tableId = sourceTable.id || sourceTable;
Metabase.table_get({
tableId: tableId
},
function(result) {
// TODO: 'native' query support
queryBuilder.card.dataset_query.query.source_table = result.id;
queryBuilder.getTableFields(result.id);
queryBuilder.inform();
},
function(error) {
console.log('error', error);
});
},
aggregationComplete: function() {
var aggregationComplete;
// TODO: 'native' query support
if ((queryBuilder.card.dataset_query.query.aggregation[0] !== null) && (queryBuilder.card.dataset_query.query.aggregation[1] !== null)) {
aggregationComplete = true;
} else {
aggregationComplete = false;
}
return aggregationComplete;
},
addDimension: function() {
// TODO: 'native' query support
queryBuilder.card.dataset_query.query.breakout.push(null);
queryBuilder.inform();
},
removeDimension: function(index) {
// TODO: 'native' query support
queryBuilder.card.dataset_query.query.breakout.splice(index, 1);
queryBuilder.inform();
},
updateDimension: function(dimension, index) {
// TODO: 'native' query support
queryBuilder.card.dataset_query.query.breakout[index] = dimension;
queryBuilder.inform();
},
setAggregation: function(aggregation) {
// TODO: 'native' query support
queryBuilder.card.dataset_query.query.aggregation[0] = aggregation;
// go grab the aggregations
queryBuilder.getAggregationFields(aggregation);
},
setAggregationTarget: function(target) {
// TODO: 'native' query support
queryBuilder.card.dataset_query.query.aggregation[1] = target;
}
},
setPermissions: function(permission) {
card.public_perms = permission;
renderHeader();
},
setQueryMode: function(mode) {
// TODO: code that handles re-render of editor
var queryTemplate = newQueryTemplates[mode];
if (queryTemplate) {
card.dataset_query = queryTemplate;
// TODO: should we carry over database here?
queryBuilder.inform();
},
updateFilter: function(value, index, filterListIndex) {
// TODO: 'native' query support
var filters = queryBuilder.card.dataset_query.query.filter;
if (filterListIndex) {
filters[filterListIndex][index] = value;
} else {
filters[index] = value;
}
}
},
getDownloadLink: function() {
// TODO: this should be conditional and only return a valid url if we have valid
// data to be downloaded. otherwise return something falsey
if (queryResult) {
return '/api/meta/dataset/csv/?query=' + encodeURIComponent(JSON.stringify(card.dataset_query));
}
}
};
queryBuilder.inform();
},
removeFilter: function(index) {
// TODO: 'native' query support
var filters = queryBuilder.card.dataset_query.query.filter;
/*
HERE BE MORE DRAGONS
1.) if there are 3 values and the first isn't AND, this means we only ever had one "filter", so reset to []
instead of slicing off individual elements
2.) if the first value is AND and there are only two values in the array, then we're about to remove the last filter after
having added multiple so we should reset to [] in this case as well
*/
if ((filters.length === 3 && filters[0] !== 'AND') || (filters[0] === 'AND' && filters.length === 2)) {
// just reset the array
queryBuilder.card.dataset_query.query.filter = [];
} else {
queryBuilder.card.dataset_query.query.filter.splice(index, 1);
}
queryBuilder.inform();
},
addFilter: function() {
// TODO: 'native' query support
var filter = queryBuilder.card.dataset_query.query.filter,
filterLength = filter.length;
// this gets run the second time you click the add filter button
if (filterLength === 3 && filter[0] !== 'AND') {
var newFilters = [];
newFilters.push(filter);
newFilters.unshift('AND');
newFilters.push([null, null, null]);
queryBuilder.card.dataset_query.query.filter = newFilters;
queryBuilder.inform();
} else if (filter[0] === 'AND') {
pushFilterTemplate(filterLength);
queryBuilder.inform();
} else {
pushFilterTemplate();
queryBuilder.inform();
}
var editorModel = {
databases: null,
initialQuery: null,
getTablesFn: function(databaseId) {
var apiCall = Metabase.db_tables({
'dbId': databaseId
});
return apiCall.$promise;
},
getTableDetailsFn: function(tableId) {
var apiCall = Metabase.table_query_metadata({
'tableId': tableId
});
return apiCall.$promise;
},
markupTableFn: function(table) {
// TODO: would be better if this was in the component
var updatedTable = CorvusFormGenerator.addValidOperatorsToFields(table);
return QueryUtils.populateQueryOptions(updatedTable);
},
runFn: function(dataset_query) {
function pushFilterTemplate(index) {
if (index) {
filter[index] = [null, null, null];
} else {
filter.push(null, null, null);
}
}
},
save: function(settings) {
var card = queryBuilder.card;
card.name = settings.name;
card.description = settings.description;
card.organization = queryBuilder.org.id;
card.display = "table"; // TODO, be smart about this
if ($routeParams.cardId) {
Card.update(card, function(updatedCard) {
queryBuilder.inform();
});
} else {
Card.create(card, function(newCard) {
$location.path('/' + org.slug + '/card/' + newCard.id);
}, function(error) {
console.log('error creating card', error);
});
Metabase.dataset(dataset_query, function (result) {
visualizationModel.result = result;
}
},
getDownloadLink: function() {
// TODO: this should be conditional and only return a valid url if we have valid
// data to be downloaded. otherwise return something falsey
if (queryBuilder.result) {
return '/api/meta/dataset/csv/?query=' + encodeURIComponent(JSON.stringify(queryBuilder.card.dataset_query));
}
},
cleanFilters: function(dataset_query) {
// TODO: 'native' query support
var filters = dataset_query.query.filter,
cleanFilters = [];
// in instances where there's only one filter, the api expects just one array with the values
if (typeof(filters[0]) == 'object' && filters[0] != 'AND') {
for (var filter in filters[0]) {
cleanFilters.push(filters[0][filter]);
}
dataset_query.query.filter = cleanFilters;
}
// reset to initial state of filters if we've removed 'em all
if (filters.length === 1 && filters[0] === 'AND') {
dataset_query.filter = [];
}
return dataset_query;
},
canRun: function() {
var canRun = false;
if (queryBuilder.aggregationComplete()) {
canRun = true;
}
return canRun;
},
run: function() {
var query = queryBuilder.cleanFilters(queryBuilder.card.dataset_query);
console.log(query);
queryBuilder.isRunning = true;
queryBuilder.inform();
// TODO: isRunning / hasJustRun state
Metabase.dataset(query, function(result) {
console.log('result', result);
queryBuilder.result = result;
queryBuilder.isRunning = false;
// we've not changed yet since we just ran
queryBuilder.hasRun = true;
queryBuilder.hasChanged = false;
queryBuilder.inform();
}, function(error) {
console.log('could not run card!', error);
});
},
setDisplay: function(type) {
// change the card visualization type and refresh chart settings
queryBuilder.card.display = type;
queryBuilder.card.visualization_settings = VisualizationSettings.getSettingsForVisualization({}, type);
queryBuilder.inform();
},
};
renderAll();
// queryBuilder.isRunning = false;
// // we've not changed yet since we just ran
// queryBuilder.hasRun = true;
// queryBuilder.hasChanged = false;
}, function (error) {
console.log('could not run card!', error);
});
}
};
var isDirty = function() {
return false;
var visualizationModel = {
card: card,
result: null,
setDisplayFn: function(type) {
// change the card visualization type and refresh chart settings
card.display = type;
card.visualization_settings = VisualizationSettings.getSettingsForVisualization({}, type);
// TODO: ideally this wouldn't be necessary
// to fix this we'd need the component to not need the card
renderVisualization();
}
};
// ===== REACT render functions
var renderHeader = function() {
React.render(new QueryHeader(headerModel), document.getElementById('react_qb_header'));
};
var renderEditor = function() {
// TODO: decide what type of editor we have
React.render(new GuiQueryEditor(editorModel), document.getElementById('react_qb_editor'));
};
var renderVisualization = function() {
React.render(new QueryVisualization(visualizationModel), document.getElementById('react_qb_viz'));
};
var renderAll = function() {
renderHeader();
renderEditor();
renderVisualization();
};
$scope.$watch('currentOrg', function (org) {
// we need org always, so we just won't do anything if we don't have one
if (!org) {return};
queryBuilder = createQueryBuilderModel(org);
// ===== Local helper functions
var loadCardAndRender = function(cardId, cloning) {
Card.get({
'cardId': cardId
}, function (result) {
console.log('card', result);
if (cloning) {
result.id = undefined; // since it's a new card
result.organization = $scope.currentOrg.id;
result.carddirty = true; // so it cand be saved right away
}
card = result;
editorModel.initialQuery = card.dataset_query;
// add a custom function for tracking dirtyness
card.isDirty = cardIsDirty;
// run the query
//queryBuilder.run();
// trigger full rendering
renderAll();
}, function (error) {
if (error.status == 404) {
// TODO() - we should redirect to the card builder with no query instead of /
$location.path('/');
}
});
};
// meant to be called once on controller startup
var initAndRender = function() {
if ($routeParams.cardId) {
// loading up an existing card
Card.get({
'cardId': $routeParams.cardId
}, function(result) {
result.isDirty = isDirty;
console.log('result', result);
queryBuilder.extractQuery(result);
queryBuilder.getDatabaseList();
// run the query
queryBuilder.run();
// execute the query
}, function(error) {
if (error.status == 404) {
// TODO() - we should redirect to the card builder with no query instead of /
$location.path('/');
}
});
loadCardAndRender($routeParams.cardId, false);
} else if ($routeParams.clone) {
// fetch the existing Card so we can set $scope.card with the existing values
Card.get({
'cardId': $routeParams.clone
}, function(result) {
$scope.setCard(result);
// execute the Card so it can be saved right away
$scope.execute(result);
// replace values in $scope.card as needed
$scope.card.id = undefined; // since it's a new card
$scope.card.organization = $scope.currentOrg.id;
$scope.carddirty = true; // so it cand be saved right away
}, function(error) {
console.log(error);
if (error.status == 404) {
$location.path('/');
}
});
loadCardAndRender($routeParams.cardId, true);
} else if ($routeParams.queryId) {
// @legacy ----------------------
// someone looking to create a card from a query
Query.get({
'queryId': $routeParams.queryId
}, function(query) {
}, function (query) {
$scope.card = {
'organization': $scope.currentOrg.id,
'name': query.name,
......@@ -494,7 +310,7 @@ CardControllers.controller('CardDetail', [
// in this particular case we are already dirty and ready for save
$scope.carddirty = true;
}, function(error) {
}, function (error) {
console.log(error);
if (error.status == 404) {
$location.path('/');
......@@ -502,17 +318,41 @@ CardControllers.controller('CardDetail', [
});
} else {
queryBuilder.getDatabaseList();
queryBuilder.card = {
name: null,
public_perms: 0,
can_read: true,
can_write: true,
display: 'none',
dataset_query: newQueryTemplates.query,
isDirty: isDirty
};
// starting a new card, so simply trigger full rendering
renderAll();
}
}); // end watch
};
// TODO: we should get database list first, then do rest of setup
// because without databases this UI is meaningless
$scope.$watch('currentOrg', function (org) {
// we need org always, so we just won't do anything if we don't have one
if (!org) {return};
// TODO: while we wait for the databases list we should put something on screen
// grab our database list, then handle the rest
Metabase.db_list({
'orgId': org.id
}, function (dbs) {
editorModel.databases = dbs;
if (dbs.length < 1) {
// TODO: some indication that setting up a db is required
return;
}
// set the database to the first db, the user will be able to change it
// TODO be smarter about this and use the most recent or popular db
//queryBuilder.setDatabase(dbs[0].id);
// NOW finish initializing our page
initAndRender();
}, function (error) {
console.log('error getting database list', error);
});
});
}
]);
\ No newline at end of file
<div class="QueryBuilder">
<div id="react"></div>
<div class="full-height">
<div class="QueryHeader full">
<div id="react_qb_header" class="QueryWrapper"></div>
</div>
<div class="QueryPicker-group">
<div class="QueryWrapper">
<div id="react_qb_editor" class="clearfix"></div>
</div>
</div>
<div id="react_qb_viz" class="QueryWrapper mb4"></div>
</div>
</div>
......@@ -13,7 +13,7 @@ var QueryHeader = React.createClass({
displayName: 'QueryHeader',
propTypes: {
card: React.PropTypes.object.isRequired,
save: React.PropTypes.func.isRequired,
saveFn: React.PropTypes.func.isRequired,
setQueryModeFn: React.PropTypes.func.isRequired,
downloadLink: React.PropTypes.string
......@@ -51,7 +51,7 @@ var QueryHeader = React.createClass({
<Saver
card={this.props.card}
hasChanged={false}
save={this.props.save.bind(this.props.model)}
save={this.props.saveFn}
/>
{downloadButton}
<AddToDashboard
......
......@@ -13,7 +13,9 @@ var QueryModeToggle = React.createClass({
},
render: function () {
// only render if the card is NEW && unmodified
if (this.props.card.id !== undefined || this.props.card.isDirty()) {
if (this.props.card.id !== undefined ||
this.props.card.isDirty() ||
!this.props.card.dataset_query) {
return false;
}
......
......@@ -4,21 +4,25 @@
var QueryVisualization = React.createClass({
displayName: 'QueryVisualization',
propTypes: {
result: React.PropTypes.object
card: React.PropTypes.object,
result: React.PropTypes.object,
setDisplayFn: React.PropTypes.func.isRequired
},
getInitialState: function () {
return {
type: 'table',
type: "table",
chartId: Math.floor((Math.random() * 698754) + 1)
};
},
componentDidMount: function () {
if (this.state.type !== 'table') {
CardRenderer[this.state.type](this.state.chartId, this.props.card, this.props.result.data);
}
this.renderChartIfNeeded();
},
componentDidUpdate: function () {
if (this.state.type !== 'table') {
this.renderChartIfNeeded();
},
renderChartIfNeeded: function () {
if (this.state.type !== "table") {
// TODO: it would be nicer if this didn't require the whole card
CardRenderer[this.state.type](this.state.chartId, this.props.card, this.props.result.data);
}
},
......@@ -26,7 +30,8 @@ var QueryVisualization = React.createClass({
this.setState({
type: type
});
this.props.setDisplay(type);
// notify our parent about our state change
this.props.setDisplayFn(type);
},
render: function () {
if(!this.props.result) {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment