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

migrate table/field metadata editing from angular -> redux.

parent 0824b4b5
No related branches found
No related tags found
No related merge requests found
import React, { Component, PropTypes } from "react";
import { connect } from "react-redux";
import _ from "underscore";
import MetabaseAnalytics from "metabase/lib/analytics";
import MetadataHeader from './MetadataHeader.jsx';
import MetadataTablePicker from './MetadataTablePicker.jsx';
import MetadataTable from './MetadataTable.jsx';
import MetadataSchema from './MetadataSchema.jsx';
import MetadataHeader from '../components/database/MetadataHeader.jsx';
import MetadataTablePicker from '../components/database/MetadataTablePicker.jsx';
import MetadataTable from '../components/database/MetadataTable.jsx';
import MetadataSchema from '../components/database/MetadataSchema.jsx';
import {
getDatabases,
getDatabaseIdfields,
getEditingDatabaseWithTableMetadataStrengths,
getEditingTable
} from "../metadataSelectors";
import * as metadataActions from "../metadata";
const mapStateToProps = (state, props) => {
return {
databases: getDatabases(state),
idfields: getDatabaseIdfields(state),
databaseMetadata: getEditingDatabaseWithTableMetadataStrengths(state),
editingTable: getEditingTable(state)
}
}
const mapDispatchToProps = {
...metadataActions
}
@connect(mapStateToProps, mapDispatchToProps)
export default class MetadataEditor extends Component {
constructor(props, context) {
super(props, context);
this.toggleShowSchema = this.toggleShowSchema.bind(this);
this.updateField = this.updateField.bind(this);
this.updateFieldSpecialType = this.updateFieldSpecialType.bind(this);
this.updateFieldTarget = this.updateFieldTarget.bind(this);
this.updateTable = this.updateTable.bind(this);
this.state = {
isShowingSchema: false
......@@ -24,55 +46,31 @@ export default class MetadataEditor extends Component {
static propTypes = {
databaseId: PropTypes.number,
tableId: PropTypes.number,
databases: PropTypes.array.isRequired,
selectDatabase: PropTypes.func.isRequired,
databaseMetadata: PropTypes.object,
tableId: PropTypes.number,
tables: PropTypes.object.isRequired,
selectTable: PropTypes.func.isRequired,
idfields: PropTypes.array.isRequired,
editingTable: PropTypes.number,
updateTable: PropTypes.func.isRequired,
updateField: PropTypes.func.isRequired,
updateFieldSpecialType: PropTypes.func.isRequired,
updateFieldTarget: PropTypes.func.isRequired
};
componentWillMount() {
// if we know what database we are initialized with, include that
this.props.initializeMetadata(this.props.databaseId, this.props.tableId);
}
toggleShowSchema() {
this.setState({ isShowingSchema: !this.state.isShowingSchema });
MetabaseAnalytics.trackEvent("Data Model", "Show OG Schema", !this.state.isShowingSchema);
}
handleSaveResult(promise) {
this.refs.header.setSaving();
promise.then(() => {
this.refs.header.setSaved();
}, (error) => {
this.refs.header.setSaveError(error.data);
});
}
updateTable(table) {
this.handleSaveResult(this.props.updateTable(table));
MetabaseAnalytics.trackEvent("Data Model", "Update Table");
}
updateField(field) {
this.handleSaveResult(this.props.updateField(field));
MetabaseAnalytics.trackEvent("Data Model", "Update Field");
}
updateFieldSpecialType(field) {
this.handleSaveResult(this.props.updateFieldSpecialType(field));
MetabaseAnalytics.trackEvent("Data Model", "Update Field Special-Type", field.special_type);
}
updateFieldTarget(field) {
this.handleSaveResult(this.props.updateFieldTarget(field));
MetabaseAnalytics.trackEvent("Data Model", "Update Field Target");
}
render() {
var tableMetadata = (this.props.databaseMetadata) ? _.findWhere(this.props.databaseMetadata.tables, {id: this.props.tableId}) : null;
var tableMetadata = (this.props.databaseMetadata) ? _.findWhere(this.props.databaseMetadata.tables, {id: this.props.editingTable}) : null;
var content;
if (tableMetadata) {
if (this.state.isShowingSchema) {
......@@ -82,10 +80,10 @@ export default class MetadataEditor extends Component {
<MetadataTable
tableMetadata={tableMetadata}
idfields={this.props.idfields}
updateTable={this.updateTable}
updateField={this.updateField}
updateFieldSpecialType={this.updateFieldSpecialType}
updateFieldTarget={this.updateFieldTarget}
updateTable={(table) => this.props.updateTable(table)}
updateField={(field) => this.props.updateField(field)}
updateFieldSpecialType={(field) => this.props.updateFieldSpecialType(field)}
updateFieldTarget={(field) => this.props.updateFieldTarget(field)}
onRetireSegment={this.props.onRetireSegment}
onRetireMetric={this.props.onRetireMetric}
/>
......@@ -102,7 +100,7 @@ export default class MetadataEditor extends Component {
<div className="p3">
<MetadataHeader
ref="header"
databaseId={this.props.databaseId}
databaseId={this.props.databaseMetadata ? this.props.databaseMetadata.id : null}
databases={this.props.databases}
selectDatabase={this.props.selectDatabase}
isShowingSchema={this.state.isShowingSchema}
......@@ -110,7 +108,7 @@ export default class MetadataEditor extends Component {
/>
<div style={{minHeight: "60vh"}} className="flex flex-row flex-full mt2 full-height">
<MetadataTablePicker
tableId={this.props.tableId}
tableId={this.props.editingTable}
tables={(this.props.databaseMetadata) ? this.props.databaseMetadata.tables : []}
selectTable={this.props.selectTable}
/>
......
import _ from "underscore";
import MetabaseAnalytics from "metabase/lib/analytics";
import MetadataEditor from './components/database/MetadataEditor.jsx';
import { augmentTable } from "metabase/lib/table";
angular
.module('metabase.admin.datamodel.controllers', [
'metabase.services',
'metabase.directives',
'metabase.forms'
])
.controller('MetadataEditor', ['$scope', '$route', '$routeParams', '$location', '$q', '$timeout', 'databases', 'Metabase', 'Segment', 'Metric',
function($scope, $route, $routeParams, $location, $q, $timeout, databases, Metabase, Segment, Metric) {
// inject the React component to be rendered
$scope.MetadataEditor = MetadataEditor;
$scope.metabaseApi = Metabase;
$scope.databaseId = null;
$scope.databases = databases;
$scope.tableId = null;
$scope.tables = {};
$scope.idfields = [];
// mildly hacky way to prevent reloading controllers as the URL changes
var lastRoute = $route.current;
$scope.$on('$locationChangeSuccess', function (event) {
if ($route.current.$$route.controller === 'MetadataEditor') {
var params = $route.current.params;
$route.current = lastRoute;
angular.forEach(params, function(value, key) {
$route.current.params[key] = value;
$routeParams[key] = value;
});
}
});
$scope.routeParams = $routeParams;
$scope.$watch('routeParams', function() {
$scope.databaseId = $routeParams.databaseId ? parseInt($routeParams.databaseId) : null
$scope.tableId = $routeParams.tableId ? parseInt($routeParams.tableId) : null
// default to the first database
if ($scope.databaseId == null && $scope.databases.length > 0) {
$scope.selectDatabase($scope.databases[0]);
}
}, true);
$scope.$watch('databaseId', async function() {
$scope.tables = {};
if ($scope.databaseId != null) {
try {
await loadIdFields();
await loadDatabaseMetadata();
$timeout(() => $scope.$digest());
} catch (error) {
console.error("error loading tables", error)
}
}
}, true);
async function loadDatabaseMetadata() {
$scope.databaseMetadata = await Metabase.db_metadata({ 'dbId': $scope.databaseId }).$promise;
$scope.databaseMetadata.tables = await Promise.all($scope.databaseMetadata.tables.map(async (table) => {
table = await augmentTable(table);
table.metadataStrength = computeMetadataStrength(table);
return table;
}));
}
async function loadIdFields() {
var result = await Metabase.db_idfields({ 'dbId': $scope.databaseId }).$promise;
if (result && !result.error) {
$scope.idfields = result.map(function(field) {
field.displayName = field.table.display_name + "" + field.display_name;
return field;
});
} else {
console.warn(result);
}
}
$scope.selectDatabase = function(db) {
$location.path('/admin/datamodel/database/'+db.id);
};
$scope.selectTable = function(table) {
$location.path('/admin/datamodel/database/'+table.db_id+'/table/'+table.id);
};
$scope.updateTable = function(table) {
// make sure we don't send all the computed metadata
let slimTable = { ...table };
slimTable = _.omit(slimTable, "fields", "fields_lookup", "aggregation_options", "breakout_options", "metrics", "segments");
return Metabase.table_update(slimTable).$promise.then(function(result) {
_.each(result, (value, key) => { if (key.charAt(0) !== "$") { table[key] = value } });
table.metadataStrength = computeMetadataStrength(table);
$timeout(() => $scope.$digest());
});
};
$scope.updateField = function(field) {
// make sure we don't send all the computed metadata
let slimField = { ...field };
slimField = _.omit(slimField, "operators_lookup", "valid_operators", "values");
return Metabase.field_update(slimField).$promise.then(function(result) {
_.each(result, (value, key) => { if (key.charAt(0) !== "$") { field[key] = value } });
let table = _.findWhere($scope.databaseMetadata.tables, {id: field.table_id});
table.metadataStrength = computeMetadataStrength(table);
return loadIdFields();
}).then(function() {
$timeout(() => $scope.$digest());
});
};
function computeMetadataStrength(table) {
var total = 0;
var completed = 0;
function score(value) {
total++;
if (value) { completed++; }
}
score(table.description);
if (table.fields) {
table.fields.forEach(function(field) {
score(field.description);
score(field.special_type);
if (field.special_type === "fk") {
score(field.target);
}
});
}
return (completed / total);
}
$scope.updateFieldSpecialType = async function(field) {
// If we are changing the field from a FK to something else, we should delete any FKs present
if (field.target && field.target.id != null && field.special_type !== "fk") {
// we have something that used to be an FK and is now not an FK
// clean up after ourselves
field.target = null;
field.fk_target_field_id = null;
}
// save the field
return $scope.updateField(field);
};
$scope.updateFieldTarget = async function(field) {
// This function notes a change in the target of the target of a foreign key
$scope.updateField(field);
};
$scope.onRetireSegment = async function(segment) {
await Segment.delete(segment).$promise;
MetabaseAnalytics.trackEvent("Data Model", "Retire Segment");
loadDatabaseMetadata();
};
$scope.onRetireMetric = async function(metric) {
await Metric.delete(metric).$promise;
MetabaseAnalytics.trackEvent("Data Model", "Retire Metric");
loadDatabaseMetadata();
};
}]);
import "./datamodel.controllers";
import { createStore } from "metabase/lib/redux";
import MetadataEditorApp from "./containers/MetadataEditorApp.jsx";
import metadataReducers from "./metadata";
angular
.module('metabase.admin.datamodel', [
'metabase.admin.datamodel.controllers'
])
.module('metabase.admin.datamodel', [])
.config(['$routeProvider', function ($routeProvider) {
var metadataRoute = {
template: '<div class="full-height spread" mb-react-component="MetadataEditor"></div>',
let MetadataRoute = {
template: '<div class="full-height spread" mb-redux-component />',
controller: 'MetadataEditor',
resolve: {
databases: ['Metabase', function(Metabase) {
return Metabase.db_list().$promise
appState: ["AppState", function(AppState) {
return AppState.init();
}]
}
}
$routeProvider.when('/admin/datamodel/database', MetadataRoute);
$routeProvider.when('/admin/datamodel/database/:databaseId', MetadataRoute);
$routeProvider.when('/admin/datamodel/database/:databaseId/:mode', MetadataRoute);
$routeProvider.when('/admin/datamodel/database/:databaseId/:mode/:tableId', MetadataRoute);
}])
.controller('MetadataEditor', ['$scope', '$location', '$route', '$routeParams', function($scope, $location, $route, $routeParams) {
$scope.Component = MetadataEditorApp;
$scope.props = {
databaseId: $routeParams.databaseId ? parseInt($routeParams.databaseId) : null,
tableId: $routeParams.tableId ? parseInt($routeParams.tableId) : null
};
$scope.store = createStore(metadataReducers, {onChangeLocation: function(url) {
$scope.$apply(() => $location.url(url));
}});
$routeProvider.when('/admin/datamodel/database', metadataRoute);
$routeProvider.when('/admin/datamodel/database/:databaseId', metadataRoute);
$routeProvider.when('/admin/datamodel/database/:databaseId/:mode', metadataRoute);
$routeProvider.when('/admin/datamodel/database/:databaseId/:mode/:tableId', metadataRoute);
// mildly hacky way to prevent reloading controllers as the URL changes
let lastRoute = $route.current;
$scope.$on('$locationChangeSuccess', function (event) {
if ($route.current.$$route.controller === 'MetadataEditor') {
var params = $route.current.params;
$route.current = lastRoute;
angular.forEach(params, function(value, key) {
$route.current.params[key] = value;
$routeParams[key] = value;
});
}
});
}]);
import _ from "underscore";
import { handleActions, combineReducers, AngularResourceProxy, createThunkAction } from "metabase/lib/redux";
import MetabaseAnalytics from "metabase/lib/analytics";
import { augmentTable } from "metabase/lib/table";
// resource wrappers
const MetabaseApi = new AngularResourceProxy("Metabase", ["db_list", "db_metadata", "db_idfields", "table_update", "field_update"]);
const SegmentApi = new AngularResourceProxy("Segment", ["delete"]);
const MetricApi = new AngularResourceProxy("Metric", ["delete"]);
async function loadDatabaseMetadata(databaseId) {
let databaseMetadata = await MetabaseApi.db_metadata({ 'dbId': databaseId });
databaseMetadata.tables = await Promise.all(databaseMetadata.tables.map(async (table) => {
table = await augmentTable(table);
return table;
}));
return databaseMetadata;
}
// initializeMetadata
export const initializeMetadata = createThunkAction("INITIALIZE_METADATA", function(databaseId, tableId) {
return async function(dispatch, getState) {
let databases, database;
try {
databases = await MetabaseApi.db_list();
} catch(error) {
console.log("error fetching databases", error);
}
// initialize a database
if (databases && !_.isEmpty(databases)) {
let db = databaseId ? _.findWhere(databases, {id: databaseId}) : databases[0];
database = await loadDatabaseMetadata(db.id);
}
if (database) {
dispatch(fetchDatabaseIdfields(database.id));
}
return {
databases,
database,
tableId
}
};
});
// fetchDatabaseIdfields
export const fetchDatabaseIdfields = createThunkAction("FETCH_IDFIELDS", function(databaseId) {
return async function(dispatch, getState) {
try {
let idfields = await MetabaseApi.db_idfields({ 'dbId': databaseId });
return idfields.map(function(field) {
field.displayName = field.table.display_name + "" + field.display_name;
return field;
});
} catch (error) {
console.warn("error getting idfields", databaseId, error);
}
};
});
// selectDatabase
export const selectDatabase = createThunkAction("SELECT_DATABASE", function(db) {
return async function(dispatch, getState) {
const { onChangeLocation } = getState();
try {
let database = await loadDatabaseMetadata(db.id);
dispatch(fetchDatabaseIdfields(db.id));
// we also want to update our url to match our new state
onChangeLocation('/admin/datamodel/database/'+db.id);
return database;
} catch (error) {
console.log("error fetching tables", db.id, error);
}
};
});
// selectTable
export const selectTable = createThunkAction("SELECT_TABLE", function(table) {
return async function(dispatch, getState) {
const { onChangeLocation } = getState();
// we also want to update our url to match our new state
onChangeLocation('/admin/datamodel/database/'+table.db_id+'/table/'+table.id);
return table.id;
};
});
// updateTable
export const updateTable = createThunkAction("UPDATE_TABLE", function(table) {
return async function(dispatch, getState) {
try {
// make sure we don't send all the computed metadata
let slimTable = { ...table };
slimTable = _.omit(slimTable, "fields", "fields_lookup", "aggregation_options", "breakout_options", "metrics", "segments");
let updatedTable = await MetabaseApi.table_update(slimTable);
_.each(updatedTable, (value, key) => { if (key.charAt(0) !== "$") { updatedTable[key] = value } });
MetabaseAnalytics.trackEvent("Data Model", "Update Table");
// TODO: we are not actually using this because the way the react components works actually mutates the original object :(
return updatedTable;
} catch (error) {
console.log("error updating table", error);
//MetabaseAnalytics.trackEvent("Databases", database.id ? "Update Failed" : "Create Failed", database.engine);
}
};
});
// updateField
export const updateField = createThunkAction("UPDATE_FIELD", function(field) {
return async function(dispatch, getState) {
const { editingDatabase } = getState();
try {
// make sure we don't send all the computed metadata
let slimField = { ...field };
slimField = _.omit(slimField, "operators_lookup", "valid_operators", "values");
// update the field and strip out angular junk
let updatedField = await MetabaseApi.field_update(slimField);
_.each(updatedField, (value, key) => { if (key.charAt(0) !== "$") { updatedField[key] = value } });
// refresh idfields
let table = _.findWhere(editingDatabase.tables, {id: updatedField.table_id});
dispatch(fetchDatabaseIdfields(table.db_id));
MetabaseAnalytics.trackEvent("Data Model", "Update Field");
// TODO: we are not actually using this because the way the react components works actually mutates the original object :(
return updatedField;
} catch (error) {
console.log("error updating field", error);
//MetabaseAnalytics.trackEvent("Databases", database.id ? "Update Failed" : "Create Failed", database.engine);
}
};
});
// updateFieldSpecialType
export const updateFieldSpecialType = createThunkAction("UPDATE_FIELD_SPECIAL_TYPE", function(field) {
return async function(dispatch, getState) {
// If we are changing the field from a FK to something else, we should delete any FKs present
if (field.target && field.target.id != null && field.special_type !== "fk") {
// we have something that used to be an FK and is now not an FK
// clean up after ourselves
field.target = null;
field.fk_target_field_id = null;
}
// save the field
dispatch(updateField(field));
MetabaseAnalytics.trackEvent("Data Model", "Update Field Special-Type", field.special_type);
};
});
// updateFieldTarget
export const updateFieldTarget = createThunkAction("UPDATE_FIELD_TARGET", function(field) {
return async function(dispatch, getState) {
// This function notes a change in the target of the target of a foreign key
dispatch(updateField(field));
MetabaseAnalytics.trackEvent("Data Model", "Update Field Target");
};
});
// retireSegment
export const onRetireSegment = createThunkAction("RETIRE_SEGMENT", function(segment) {
return async function(dispatch, getState) {
const { editingDatabase } = getState();
await SegmentApi.delete(segment);
MetabaseAnalytics.trackEvent("Data Model", "Retire Segment");
return await loadDatabaseMetadata(editingDatabase.id);
};
});
// retireMetric
export const onRetireMetric = createThunkAction("RETIRE_METRIC", function(metric) {
return async function(dispatch, getState) {
const { editingDatabase } = getState();
await MetricApi.delete(metric);
MetabaseAnalytics.trackEvent("Data Model", "Retire Metric");
return await loadDatabaseMetadata(editingDatabase.id);
};
});
// reducers
// this is a backwards compatibility thing with angular to allow programmatic route changes. remove/change this when going to ReduxRouter
const onChangeLocation = handleActions({}, () => null);
const databases = handleActions({
["INITIALIZE_METADATA"]: { next: (state, { payload }) => payload.databases }
}, []);
const idfields = handleActions({
["FETCH_IDFIELDS"]: { next: (state, { payload }) => payload }
}, []);
const editingDatabase = handleActions({
["INITIALIZE_METADATA"]: { next: (state, { payload }) => payload.database },
["SELECT_DATABASE"]: { next: (state, { payload }) => payload ? payload.database : state },
["RETIRE_SEGMENT"]: { next: (state, { payload }) => payload },
["RETIRE_METRIC"]: { next: (state, { payload }) => payload }
}, null);
const editingTable = handleActions({
["INITIALIZE_METADATA"]: { next: (state, { payload }) => payload.tableId || null },
["SELECT_TABLE"]: { next: (state, { payload }) => payload }
}, null);
export default combineReducers({
databases,
idfields,
editingDatabase,
editingTable,
onChangeLocation
});
import { createSelector } from 'reselect';
import { computeMetadataStrength } from "metabase/lib/schema_metadata";
export const getDatabases = state => state.databases;
export const getDatabaseIdfields = state => state.idfields;
export const getEditingTable = state => state.editingTable;
export const getEditingDatabaseWithTableMetadataStrengths = createSelector(
state => state.editingDatabase,
(database) => {
if (!database || !database.tables) {
return null;
}
database.tables = database.tables.map((table) => {
table.metadataStrength = computeMetadataStrength(table);
return table;
});
return database;
}
);
......@@ -510,3 +510,25 @@ export const ICON_MAPPING = {
export function getIconForField(field) {
return ICON_MAPPING[getFieldType(field)];
}
export function computeMetadataStrength(table) {
var total = 0;
var completed = 0;
function score(value) {
total++;
if (value) { completed++; }
}
score(table.description);
if (table.fields) {
table.fields.forEach(function(field) {
score(field.description);
score(field.special_type);
if (field.special_type === "fk") {
score(field.target);
}
});
}
return (completed / total);
}
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